diff --git a/.gitignore b/.gitignore index 0ac88c02..f6ae8d2b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist/ # Runtime data (screenshots, DB, cast files) .redlog/ test-results/ +__pycache__/ diff --git a/config.example.yaml b/config.example.yaml index 3b1a4852..a9deeb57 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -27,13 +27,31 @@ network: # Scope definition (optional) scope: - # warn | block | log - enforcement: warn + # How far down the distance ladder alerts fire: + # excluded_only D1 only — targets you explicitly forbade + # adjacent D1 + D2 — also "right subnet / right domain, wrong host" (default) + # all D1 + D2 + D3 — strict: any target not on the list is on the record + # D1 fires from every position: an explicit exclusion is not a preference call. + alertFloor: adjacent + # Legacy, migrated on load: `enforcement: warn|log` → `warnOnViolation` → `alertFloor`. + # (`block` was removed — RedLog never blocks, see docs/ALERT-ROLES.md Part D.) # In-scope targets (IPs, CIDRs, wildcard domains) targets: - 10.0.0.0/8 - 192.168.1.0/24 - "*.example.com" + # Container width for the D2 "same subnet, wrong host" alert. It applies only + # to SINGLE-IP scope entries: had the list above contained 192.168.1.10, then + # 192.168.1.0/24 would count as adjacent and hitting .55 would warn. Entries + # already written as CIDRs (10.0.0.0/8) state their own boundary and are never + # widened; outside them is unrelated, not adjacent. + proximityBits: 24 + # Extra multi-label public suffixes, used to decide "same registrable domain" + # for the D2 same-domain alert. RedLog ships a curated table (co.uk, com.tw, + # github.io, s3.amazonaws.com, ...); add here only what it does not know. An + # unknown suffix over-matches (noisy) rather than going silent, so this is a + # noise-reduction knob, not a safety-critical one. + publicSuffixes: [] # Explicitly excluded targets excludeTargets: - 10.0.0.1 diff --git a/docs/ALERT-ROLES.md b/docs/ALERT-ROLES.md new file mode 100644 index 00000000..7b408971 --- /dev/null +++ b/docs/ALERT-ROLES.md @@ -0,0 +1,726 @@ +# Alert Roles — how RedLog warns without lying + +Written 2026-08-14. Applies `DECOMPOSITION-METHOD.md` to the **alerting** subsystem +(`ip-monitor.ts`, `scope-monitor.ts`, and the badge/flash/event/webhook surfaces they +feed). It is the 10th subsystem in the map and the first one whose members are +*verdicts*, not components. Its job: **every warning RedLog raises is one of two +roles, carries an explicit authority (fact / inferred / unknown), and sits at a named +distance from the operator's declared intent — so "add an alert" is filling a +template, and no verdict can present an inference as a fact.** + +RedLog never blocks. An alert is therefore the *entire* intervention — if it is +wrong-coloured, there is no second line of defence. + +## Principle + +> **A green light must be a fact. Everything else is a shade of "I don't know yet."** + +The failure mode this subsystem exists to prevent is not a missed alert — it is a +**confident green**. An operator glancing at the status bar and seeing SAFE while +their VPN has silently dropped is worse than no indicator at all, because it +converts uncertainty into false assurance. + +## The two roles (mechanism axis: signal shape) + +``` +"Where am I?" — periodic poll of my own egress position → SELF ALARM (state machine) +"What did I hit?" — per-action evaluation of a target → TARGET ALARM (event stream) +``` + +The shapes are genuinely different and that difference explains every asymmetry in +today's code: + +| | **Self alarm** | **Target alarm** | +|---|---|---| +| Trigger | timer (`checkInterval`, default 10s) | each `command_start` with an extracted target | +| Output | a *level* that persists until changed | a *record* that happened once | +| Needs hysteresis? | **yes** — flapping egress must not flap the badge (`confirmations`, `settling`) | **no** — every occurrence is its own fact | +| Needs distance? | **no** — you are either at an expected address or not | **yes** — a target can be near-miss or unrelated | +| Failure to read | must degrade the level (stale) | no reading = no event | +| Instance | `IPMonitor` | `ScopeMonitor` | + +**Do not port hysteresis to Target alarms** (each command is discrete) and **do not +port the distance ladder to Self alarms** (there is no "nearly my VPN"). + +## The tier axis (authority → the rules, not the shape) + +Inherited from §3 / K1 (`DECOMPOSITION-BACKLOG.md`): every verdict declares whether +it is observed or inferred. + +| Tier | Meaning | Rules it carries | +|---|---|---| +| **fact** | a configured rule matched literally | cannot be silenced by a preference toggle; renders solid; always forwards to deconfliction | +| **inferred** | derived by proximity or by absence-of-match | must render dashed/qualified; silenceable per engagement; must name the inference in the event | +| **unknown** | insufficient data (no lists, no reading, no extractable target) | is its **own colour** — never collapses into safe | + +The single largest defect in the subsystem today is a tier violation: an **inferred** +verdict ("your IP isn't on the blacklist") is rendered with the **fact** presentation +(solid green SAFE). See A-3 and A-9 below. + +--- + +# Part A — Self alarm: the IP verdict combination matrix + +## A.1 The base combination (what is configured × what matched) + +Four independent bits produce the whole space: `W` = whitelist configured, `B` = +blacklist configured, `w` = IP ∈ whitelist, `b` = IP ∈ blacklist. Nine reachable +combinations (`w`/`b` are undefined when the corresponding list is empty): + +| # | W | B | b | w | Should be | Tier | `classify()` today | Verdict | +|---|:-:|:-:|:-:|:-:|---|---|---|---| +| A-1 | ✗ | ✗ | – | – | `unknown` | unknown | `unknown` | ✅ | +| A-2 | ✗ | ✓ | ✓ | – | `exposed` | fact | `exposed` | ✅ | +| A-3 | ✗ | ✓ | ✗ | – | `presumed_safe` | **inferred** | `presumed_safe` | ✅ **fixed** (G-A2) | +| A-4 | ✓ | ✗ | – | ✓ | `safe` | fact | `safe` | ✅ | +| A-5 | ✓ | ✗ | – | ✗ | `off_profile` | fact | `off_profile` | ✅ **fixed** (G-A2) | +| A-6 | ✓ | ✓ | ✓ | ✓ | `exposed` + conflict | fact | `exposed` + `listConflict` | ✅ **fixed** (G-A2) | +| A-7 | ✓ | ✓ | ✓ | ✗ | `exposed` | fact | `exposed` | ✅ | +| A-8 | ✓ | ✓ | ✗ | ✓ | `safe` | fact | `safe` | ✅ | +| A-9 | ✓ | ✓ | ✗ | ✗ | `off_profile` | fact | `off_profile` | ✅ **fixed** (G-A1 then G-A2) | + +**A-9 was the dangerous cell — fixed (G-A1).** `classify()` used to fall through to +`if (this.blacklist.length > 0) return 'safe'` *after* the whitelist test had already +failed. Concretely: whitelist `10.8.0.0/24` (my VPN), blacklist `1.2.3.4` (my home +IP), actual egress `5.6.7.8` (VPN dropped, now on café NAT) → **SAFE, solid green**. +The blacklist-mode shortcut was written for the blacklist-only case (A-3) and was +never re-scoped when a whitelist is also present. + +The rule now reads: **declaring a whitelist declares an expectation; being outside it +is never `safe`.** G-A1 closed the false green by routing A-5 and A-9 to `unknown`; +G-A2 then gave them the verdict they actually deserve. Amber said "I don't know", +where the truth is the stronger "you are not where you said you would be" — an +observed deviation filed as missing information. + +**A-5 is the mirror mistake.** Declaring a whitelist *is* declaring an expectation; +being outside it is an observed deviation (fact), not a lack of information +(`unknown`). Today it lands in the same amber bucket as "nothing configured at all." + +## A.2 The verdict vocabulary this implies + +Three states cannot express nine cells. The closed set is five, plus modifiers: + +| Verdict | Tier | Meaning | Tone | Qualified? | +|---|---|---|---|---| +| `exposed` | fact | on the blacklist — real identity is leaking | red | no (flashes) | +| `off_profile` | fact | a whitelist exists and this is not in it | orange | no | +| `safe` | fact | on the whitelist | green | no | +| `presumed_safe` | **inferred** | blacklist-only, no match — "not obviously you" | green | **yes** — hollow, unglowing | +| `unknown` | — (no claim) | nothing configured / no reading | amber | no | + +**Five verdicts, four tones.** `presumed_safe` shares the `safe` tone and is +separated by `qualified`, so a surface paints four colours rather than five and an +inference can never render as a solid green fill. That reuses the `lib/ip-badge.ts` +mechanism G-A3 built for `stale`/`settling` — the precedence runs from "how much do +we know" outward: no reading beats an unconfirmed reading beats a confirmed reading +we can only infer from. + +`verdictAuthority()` maps each verdict onto K1's `Authority`, so no surface +re-derives "is this an inference?" from the verdict's name. `unknown` returns `null`: +it asserts nothing, so it makes no claim to a tier. The `ip_transition` event stamps +the result per event — a transition into `presumed_safe` records an inference while +every other one records an observation, the same split-authority shape as +`scope_violation`. + +**A-6 (`listConflict`)** is reported *alongside* the verdict, not as one. The verdict +is `exposed` and correct — the blacklist wins — but a red badge looks like every +other red badge, so the operator would never learn their two lists contradict each +other. It says something about the CONFIG, not about where the operator is. + +## A.3 The confidence modifiers (orthogonal — they multiply, not add rows) + +These do **not** create new cells; they qualify any cell. This is the part that keeps +the matrix from exploding. + +| Modifier | Source | Status | +|---|---|---| +| `settling` | new address seen < `confirmations` times | ✅ **shipped** — badge shows the last stable verdict, hollow dot + reason | +| `stale` | `consecutiveFailures` reached `network.staleAfter` | ✅ **shipped** — verdict decays to `unknown`, last address retained | +| `family` | reading is IPv6 | ❌ silently mis-classified (below) — G-A5 | + +**`stale` was a live hazard.** A VPN kill-switch dropping the network is exactly when +external-IP lookup fails — and the badge kept showing the last verdict (often green) +indefinitely, with only `lastCheck` moving. Neither modifier was even *declared* on +the renderer's `IPStatus`, so no surface could have shown them. + +Three rules the fix pins down: + +1. **The decay is uniform.** An `exposed` verdict decays too. Holding a red alarm on + a reading we can no longer confirm is the same dishonesty as holding a green one — + `unknown` is what we actually know. The last seen address stays on screen, so the + decay re-labels rather than erases. +2. **`staleAfter` (default 2) is deliberately tighter than `confirmations` + (default 3).** The thresholds look alike but point opposite ways: being slow to + *promote* a new address is safe — you keep showing a verdict you verified. Being + slow to *expire* an old one is not. One failure can be a provider hiccup; two in a + row at the shipped 60s poll is two minutes of no contact. +3. **One decision, three surfaces.** `lib/ip-badge.ts` maps (verdict, settling, + stale) → (tone, qualified, reason). StatusBar, IPStatusCard and the HUD keep their + own styling but cannot disagree about the verdict. `qualified` renders as a hollow, + unglowing dot — a filled dot is reserved for "this is what we see right now". + +**Two further defects surfaced while fixing this**, both in the same +unchanged-address branch of `check()`, which reset the flags but never re-ran +`classify()`: + +- A decayed verdict **never recovered** if the network came back on the *same* exit IP + — the common case for a VPN blip. The badge stayed amber indefinitely. +- Editing the safe/exposed lists in Settings **did not move the badge** until the + external address happened to change. + +The verdict is a pure function of (address, lists), so it is now derived on every +poll rather than only on address change. + +**IPv6 was structurally broken — fixed (G-A5).** `ipInCIDR()` compared an IPv6 +address to a CIDR by string-equality against the network part, so `2001:db8::1` ∈ +`2001:db8::/32` returned **false**. Any v6 whitelist never matched (→ A-5/A-9), and a +v6 egress against a v4-only blacklist took the A-3 fall-through → `safe`. The A-9 fix +did not reach v6 at all. + +`ip-match.ts` now does real prefix matching for both families, and is the **single** +matcher for the subsystem — `ip-monitor.ts` and `scope-monitor.ts` each carried their +own `ipToLong` + CIDR copy before. It also compares parsed values rather than strings, +so `2001:db8::1` matches the fully expanded `2001:0db8:0000:...:0001`, and reports an +IPv4-mapped address (`::ffff:10.8.0.5`) as IPv4 — providers return that form, and an +operator whose whitelist says `10.8.0.0/24` means it to match. Malformed input is +`false`, never an accidental wildcard. + +**The scope side had the same defect in a worse direction (G-B5).** `scope-monitor`'s +`IP_RE` matched only dotted quads, so a v6 target was routed through the *domain* +matcher: a v6 CIDR scope entry never matched, and on the adjacency path a v6 host fell +out as `unrelated` — **silent**. Per Part D that is the one direction this subsystem +must not fail in, so the fix covers both monitors rather than only the one the gap row +named. A single-IP v6 scope entry expands to its **/64** rather than to +`proximityBits`: v6 subnets are /64 near-universally, so there is no operator choice +to expose (Part C.4). + +**Remaining v6 limitation (not a defect):** the DNS lookup path filters to v4 +(`IPV4_RE`), so a v6 external address is only ever *read* in `http` / fallback mode. + +## A.4 The unclassified half — fixed (G-A4) + +`internalIP` was collected, displayed, and **never classified**. A laptop that +silently reassociated to a guest SSID mid-engagement read exactly like one still on +the client VLAN, and the external verdict cannot catch it: the egress can be +perfectly fine while you are on the wrong network. + +`network.lanProfile` declares the internal segments the engagement expects, and the +prediction in this section held — **the same whitelist machinery serves it**, with no +new verdict vocabulary and no new severity step. `classifyIP` runs with the profile +as the whitelist and no blacklist, so exactly three of the nine cells are reachable: + +| lanProfile | Internal address | Verdict | +|---|---|---| +| unset | anything | `unknown` — nothing declared, nothing claimed (A-1) | +| set | inside a listed CIDR | `safe` (A-4) | +| set | outside every listed CIDR | `off_profile` (A-5) | + +**There is deliberately no LAN blacklist.** "This is my own segment" is what the +profile already says, from the other direction — adding a second list would give the +same fact two contradictory homes. + +**`lanSafety` never goes stale.** It is a local read of the network interfaces, so +nothing about it expires when an external lookup fails. Fixing that surfaced a +pre-existing defect: `Promise.all` discarded a perfectly good internal read along +with the external rejection. The failure path now re-reads it — losing your LAN +verdict because the *internet* died is exactly backwards, since dropping off the +client VLAN is more likely precisely when the network is misbehaving. + +A move between internal segments joins the existing `ip_transition` event +(`from_lan_safety` / `to_lan_safety`), because it is the same class of drift signal +as the egress changing. + +**Considered and not done:** feeding `internalIP` into the Target alarm's D2 +subnet-proximity rule — "the target is on *my* segment but not in scope". The +containers there are derived from *scope entries*, which is a statement about what +was authorised; deriving one from wherever the operator's laptop happens to sit is a +different claim, and adding D2 sources is exactly what the ladder decomposition +exists to keep deliberate. It would need its own row, not a quiet widening of this +one. + +--- + +# Part B — Target alarm: the distance ladder + +## B.1 The four distances + +The mechanism is **distance from declared intent**, and it is the same ladder for IPs +and for domains: + +| | Distance | Condition | Tier | Alert | +|---|---|---|---|---| +| **D0** | in scope | matches `targets` | — | none | +| **D1** | forbidden | matches `excludeTargets` | fact | **always** — not silenceable | +| **D2** | adjacent | in a scope entry's *container*, not in `targets` | inferred | warn (silenceable via `warnOnViolation`) | +| **D3** | unrelated | outside every container | — | silent (count only) | + +D1 outranks D0 by design (an explicit exclusion inside a broad CIDR must still fire) — +that ordering is already correct in `checkTarget()`. + +## B.2 The container rule (this is the whole of "same subnet / same domain") + +D2 needs a definition of "near." The rule is symmetric across both address families +and closes in one line: + +> **A single point expands one level into its container. An entry that already *is* a +> container does not expand.** + +| Scope entry | Kind | Container (= the D2 zone) | Rationale | +|---|---|---|---| +| `192.168.1.10` | single IP | `192.168.1.0/24` | a bare IP carries no boundary; deriving one *fills a gap* | +| `10.0.0.0/8` | CIDR | *(none)* | the operator stated a boundary; widening it *invents authorisation* | +| `10.0.5.0/24` | CIDR | *(none)* | same | +| `www.example.com` | single host | `*.example.com` (registrable domain) | sibling subdomains are the near-miss zone | +| `*.staging.example.com` | wildcard | `*.example.com` (registrable domain) | **still expands** — see the asymmetry below | + +**Correction (2026-08-14, during implementation).** An earlier revision of this +table said a wildcard entry does *not* expand, by analogy with CIDRs. That was +wrong, and the counterexample is the case D2 most exists for: scope +`*.staging.example.com`, operator hits `prod.example.com`. Under "no expansion" +that is D3 — silent. It must be D2. + +The asymmetry is real and deliberate: **the domain container is always the +registrable domain, because that domain is the *ownership* boundary the +authorisation is actually about.** A wildcard is a sub-boundary *within* something +owned, not the ownership boundary itself. A CIDR has no comparable ownership +boundary recoverable from the string — `10.0.0.0/8` tells you nothing about whether +the org also owns `192.168.0.0/16` — which is why it cannot expand. The rule at the +top of this section governs the IP side; the domain side always expands. + +So both requested alerts fall out of one rule: + +- **同網段但非指定 IP** — scope lists `192.168.1.10`, `192.168.1.20`; operator hits + `192.168.1.55` → inside the derived `/24` container, not in `targets` → **D2**. + Almost always "right subnet, wrong box" — the highest-value warning in the ladder. +- **同 domain 但非指定 subdomain** — scope lists `www.example.com`; operator hits + `admin.example.com` → inside `*.example.com`, not in `targets` → **D2**. + +The container prefix length for single IPs must be configurable +(`scope.proximityBits`, default 24): a /24 assumption is right for lab and internal +ranges and wrong for a routed /30 hand-off. + +## B.3 Where `ScopeMonitor` sits today + +| Case | Ladder | Status | +|---|---|---| +| in `targets` | D0 | ✅ | +| in `excludeTargets` | D1 | ✅ fires regardless of `warnOnViolation` | +| domain, shares registrable domain | D2 `adjacent_domain` | ✅ — but the registrable-domain approximation is still wrong (B.4 / G-B2) | +| domain, different root | D3 | ✅ counted | +| single-IP scope entry, same segment | D2 `adjacent_subnet` | ✅ **shipped (G-B1)** | +| IP outside every container | D3 | ✅ **shipped (G-B3)** — was "violation, always" | + +Before G-B1/G-B3 the `if (!isIP)` guard in `checkTarget()` meant the proximity filter +applied to domains only; IPs skipped it and every out-of-scope IP raised. That is the +classic alerting death spiral — the noisy channel gets muted, and the D2 signal dies +with it. Both branches now walk the same ladder via the pure `classifyDistance()`. + +**D3 is counted, not dropped** (`getUnrelatedCount()`): "silent" must be +distinguishable from "not looking", and the adherence report (G-D1) needs the +denominator. Note also that a D3 target still lands in the timeline as +`detectedTarget` like any other command — only the *alert* is suppressed, never the +record. + +## B.4 The registrable-domain defect — fixed (G-B2) + +`getRootDomain()` took the last two labels. So `shop.example.co.uk` → `co.uk`, and +`anything.bbc.co.uk` → `co.uk` as well. With scope `*.example.co.uk`, **every +`.co.uk` host in the world became D2-adjacent.** The platform half bit harder in +practice: a scope of `target.github.io` made every GitHub Pages site a near-miss, +and the same for `*.s3.amazonaws.com`, `*.azurewebsites.net`, `*.herokuapp.com`. + +`public-suffix.ts` now derives the true registrable domain (public suffix + one +label, longest suffix wins) from a curated table, with `scope.publicSuffixes` for +per-engagement additions. + +**Why a curated table and not the full PSL.** The full list means a runtime +dependency (~3 MB; `tldts` is present only as a `jsdom` dev-transitive, not in the +shipped app). RedLog ships eight runtime dependencies deliberately — for an evidence +tool, supply-chain surface is a cost every user pays. The deciding argument is the +**failure direction**, which is asymmetric: + +| Table error | Effect | Verdict | +|---|---|---| +| suffix **missing** | falls back to last-two-labels → over-match → noisier D2 | exactly today's behaviour — degrades to the status quo, never below it | +| suffix **wrongly present** | same-owner hosts get different registrable domains → D3 → **silence** | unacceptable: Part D means there is no second line of defence | + +So the table carries only unambiguous suffixes; anything doubtful is left out, +costing noise rather than silence, and operators extend it per engagement. That +property is pinned by a test, not just asserted here. + +**Known gap:** regional S3 forms (`s3.us-east-1.amazonaws.com`) are absent and fall +back to `amazonaws.com` — over-match, per the safe direction above. + +## B.5 What D2 must say when it fires + +A D2 alert is **inferred**, so per the tier axis it must name its inference. The +`reason` vocabulary is now closed (G-B4) — it used to write `out_of_scope` for both +the domain-adjacent case and (via the IP path) the unrelated case, leaving them +indistinguishable downstream: + +`excluded_target` (D1, fact) · `adjacent_subnet` (D2, inferred) · +`adjacent_domain` (D2, inferred) · `unrelated` (D3 — counted, not emitted) + +The `scope_violation` event also carries `authority: 'fact' | 'inferred'` — the data +half of the §3 split. K1's minimal slice has since landed the rest: `core/authority.ts` +resolves authority for **every** event type (per-event stamp first, then +`EventTypeDef.authority`, then a built-in table, then `fact`), `insertEvent` writes +`inferred` into the hashed row, and `lib/dotShape.ts` renders it as a dashed, +unfilled dot — the same statement the phase ribbon was already making, no longer +phase-only. + +`scope_violation` is the case that forced per-event precedence: it is `fact` for an +excluded target and `inferred` for a proximity match, so no type-level default can be +right for it. That corrected a mis-classification in `EVENT-TYPE-VOCABULARY.md`, which +had filed it under detector-derived (uniformly inferred) *and* under `system` +(uniformly authoritative). + +That pair now drives the deconfliction feed (G-C2). Both fields ride **outside** the +`includeData` PII gate, because they are bounded enums: a receiver must be able to +triage a `scope_violation` without being handed the command text. `authorityFloor: +'fact'` holds inferences back; the default still forwards both, labelled. + +**Why the default is not fact-only.** Both tiers describe activity that really +happened — the tier is about how confident RedLog is that it *matters*, not about +whether it occurred. Quietly telling the blue team less about real out-of-scope +activity is the wrong direction to fail in, so narrowing an outward feed is an +explicit act. + +**Wire-format note:** consumers parsing exported violations will see +`adjacent_domain` where they previously saw `out_of_scope`. + +--- + +## Completeness + +Every warning-producing site in the codebase maps to exactly one role: + +| Site | Role | Tier | Surface | +|---|---|---|---| +| `ip-monitor.ts` `classify()` | Self | fact / inferred (A-3) | `IPStatus.ipSafety` | +| `ip-monitor.ts` `settling` | Self (modifier) | inferred | (unrendered) | +| `ip-monitor.ts` `error` | Self (modifier) | unknown | `IPStatus.error` | +| `main/index.ts` safety-change event | Self → record | fact | `system` event → deconfliction | +| `scope-monitor.ts` excluded branch | Target | fact | `scope_violation` | +| `scope-monitor.ts` root-domain branch | Target | inferred | `scope_violation` | +| `scope-monitor.ts` IP out-of-scope branch | Target | **mis-tiered** (fact presentation, D3 content) | `scope_violation` | +| `StatusBar` / `IPStatusCard` / overlay `flashOnExposed` | Self, render | — | badge/flash | +| `ScopeStatus` badge, `Timeline` diamond | Target, render | — | badge/glyph | + +**Honest boundary:** complete over the *current* surface. Two things the operator may +reasonably call "an alert" are deliberately out of this decomposition because they are +other subsystems' members: loot/credential detections (`DETECTOR-ROLES.md`) and +capture-health "dark/partial" (a health verdict, not a warning). + +## Per-role template + +Both roles fill the same headings: + +1. **Contract** — the closed verdict vocabulary it may emit. +2. **Tier per verdict** — fact / inferred / unknown, declared, not implied. +3. **Degradation** — what the verdict becomes when the input is missing. (Self: + decays to `unknown`. Target: emits nothing.) +4. **Silenceability** — fact verdicts are never silenceable; inferred ones name the + config flag that mutes them. +5. **Surface binding** — which badge/glyph/event/webhook renders it, and how the tier + changes the rendering (solid vs dashed/outline). +6. **Test seam** — Self: a table-driven test over the combination matrix. Target: a + table-driven test over the distance ladder. + +## The severity scale (G-C1) + +The two roles grew their colour vocabularies independently and they did not line up. +The Self alarm had four tones, one per verdict. The Target alarm had an **on/off +light** — `scopeViolations > 0 ? red : green` — so a D1 hit on an explicitly +forbidden host and a D2 proximity inference rendered identically. G-B4 made them +distinguishable in the data and G-C2 on the wire; the operator's eye was where the +distinction stopped. + +One scale, four steps, both roles: + +| Severity | Colour | Self alarm | Target alarm | +|---|---|---|---| +| `critical` | red | `exposed` | D1 `excluded_target` | +| `warn` | orange | `off_profile` | D2 `adjacent_subnet` / `adjacent_domain` | +| `unknown` | amber | `unknown` | — | +| `notice` | grey | — | D3 `unrelated`, only under `alertFloor: 'all'` (G-C3) | +| `ok` | green | `safe`, `presumed_safe` | D0, silenced D3 | + +`notice` ranks below `unknown`: "here is something that happened, out of scope but +unrelated" asks less of the operator than "I cannot tell you whether you are safe". + +D1 sits level with `exposed` deliberately: both are the thing the operator must not +do, both observed, both non-silenceable — one is an OPSEC failure, the other an +authorisation failure. + +**Severity and authority are orthogonal — do not fold one into the other.** + +- **severity** (this scale) — how loudly to shout. Sets the **colour**. +- **authority** (K1) — observed or inferred. Sets the **fill**: solid, or hollow + and unglowing. + +That is why `presumed_safe` and a D2 near-miss are both inferred yet look nothing +alike: `ok` + inferred is a hollow green, `warn` + inferred a hollow orange. +Collapsing the axes would force one of those two to lie. It is also why +`presumed_safe` is `ok` and not `warn` — it is the *good* answer, merely an inferred +one, and the caveat belongs on the authority axis rather than in an inflated colour. + +`lib/alertSeverity.ts` owns the levels and both colour tables (`SEVERITY_HUD` for the +overlay, `SEVERITY_CLASS` for the app), replacing three hand-maintained per-verdict +maps. `worstSeverity()` gives the summary rule: one observed rule match outranks any +number of inferences. + +## Gaps (backlog) + +Fifteen in total, split across two tables — twelve here (A/B/C) and three under +`D.5` (`G-C3`, `G-D1`, `G-D2`). All fifteen are now closed. Which commit closed +what, so the arithmetic is checkable rather than asserted: + +| Commits | Gaps closed | Count | +|---|---|---| +| `4cc4ca7`, `594156b`, `9518079` | G-A1, G-A3, G-A5, G-B1, G-B2, G-B3, G-B4, G-B5 | 8 | +| `5e155a1` | *(none — the K1 `authority` primitive the rest depend on)* | 0 | +| `c99d78f` | G-A2, G-A4, G-C1, G-C2, G-C3, G-D1, G-D2 | 7 | + +> **Errata.** `c99d78f`'s commit message says it closes "the remaining fourteen +> gaps". It closes **seven** — the number above. The message is wrong and is left +> as written: correcting it means rewriting history on a shared branch, which +> costs more than the error does. This table is the accurate record. + +| # | Gap | Kind | Notes | +|---|---|---|---| +| **G-A1** | ~~`classify()` returns `safe` when both lists are configured and neither matches (A-9)~~ | ✅ **fixed** | A whitelist miss is never `safe`. Verdict is `unknown` pending the `off_profile` state (G-A2). Test seam: exported pure `classifyIP()`, table-driven over A-1..A-9 in `test/ip-monitor.test.ts`. | +| **G-A2** | ~~No `off_profile` / `presumed_safe` verdicts — 3 states cannot encode 9 cells~~ | ✅ **fixed** | Five verdicts, four tones, `verdictAuthority()` mapping onto K1's `Authority`. A-6 surfaced as `listConflict`. All nine cells now encodable. | +| **G-A3** | ~~`settling` and `error` never reach the badge; verdict never decays~~ | ✅ **fixed** | Decay to `unknown` after `network.staleAfter` (default 2); shared `lib/ip-badge.ts` renders both modifiers on all three surfaces. Also fixed: verdict never re-derived on an unchanged address. | +| **G-A4** | ~~`internalIP` collected but never classified; no lan-profile~~ | ✅ **fixed** | `network.lanProfile` + `IPStatus.lanSafety`, reusing `classifyIP` — no new vocabulary. Also fixed: a failed external poll used to discard the local internal read. Feeding it into D2 proximity was considered and declined (A.4). | +| **G-A5** | ~~IPv6 CIDR matching is string-equality — v6 whitelists never match~~ | ✅ **fixed** | Shared `ip-match.ts` (both families, parsed-value comparison, v4-mapped handling). Replaced two duplicate matchers. | +| **G-B1** | ~~No container derivation for single-IP scope entries~~ | ✅ **fixed** | Single-IP entries expand to `scope.proximityBits` (default 24). CIDR entries never widen. | +| **G-B2** | ~~`getRootDomain()` = last two labels → `.co.uk`/`.com.tw` over-match~~ | ✅ **fixed** | Curated table in `public-suffix.ts` + `scope.publicSuffixes`. No new runtime dependency; incomplete → noise, never silence. | +| **G-B3** | ~~IP out-of-scope bypasses the proximity filter~~ | ✅ **fixed** | Both branches walk one ladder via pure `classifyDistance()`. D3 counted via `getUnrelatedCount()`. | +| **G-B4** | ~~`reason` vocabulary is not closed~~ | ✅ **fixed** | Closed to 3 values + `authority` on the event. G-C2 (tier-aware forwarding) is now unblocked. | +| **G-B5** | ~~`IP_RE` is dotted-quad only, so a v6 target is routed to the domain matcher and falls out as `unrelated` — silent~~ | ✅ **fixed** | The scope-side twin of G-A5, found while fixing it. Same shared matcher; v6 single-IP entries expand to /64. | +| **G-C1** | ~~No shared severity vocabulary between Self and Target alarms~~ | ✅ **fixed** | Four-step scale in `lib/alertSeverity.ts`, both roles mapped onto it. The Target alarm had no scale at all — it was an on/off light — so D1 and D2 now differ on screen, and violations carry `reason` + `authority` to the UI. | +| **G-C2** | ~~Deconfliction forwards `scope_violation` wholesale~~ | ✅ **fixed** | Every forwarded event carries `authority` + `reason` outside the `includeData` gate; `deconfliction.authorityFloor` can hold inferences back. Default still forwards both. | + +## Cross-references + +- **`DECOMPOSITION-METHOD.md`** — the method; add alerting as the 10th row. +- **`DECOMPOSITION-BACKLOG.md` K1** — the `authority: fact | inferred` primitive. + This subsystem is its second consumer; G-A2/G-B4 should land on top of it, not + beside it. +- **`SPEC-SCOPE-AWARE-LIFECYCLE.md`** — `classifyTarget()` is the *pure* sibling of + `checkTarget()`. The distance ladder must be added there too, or sanitize and + alerting will disagree about what "out of scope" means. +- **`DELIVERY-TARGETS.md`** — the deconfliction Stream is an alert consumer (G-C2). +- **`DETECTOR-ROLES.md`** — loot/credential detections are Detectors, not Alerts. + +--- + +# Part C — Toggles: what may be silenced, and at what granularity + +## C.1 The rule (falls out of the tier axis) + +> **Verdicts have no switches. Surfaces do.** +> Within surfaces: **fact-tier alerts may be de-emphasised, never removed; +> inferred-tier alerts may be silenced.** + +`overlay.flashOnExposed` is the existing correct precedent: it does not make +`exposed` disappear, it only stops the flash. Any new toggle must be checkable +against that shape — if flipping it can make a fact-tier verdict *unobservable*, +it is the wrong toggle. + +## C.2 Self alarm: zero new toggles + +| Candidate | Verdict | +|---|---| +| "silence `exposed`" | ✗ fact — never | +| "silence `off_profile`" | ✗ fact — never | +| "opt out of `presumed_safe` outlining" | ✗ not a toggle — it is a *presentation of uncertainty*; making it optional re-creates the false green (A-3) | +| "hide `settling` / `stale`" | ✗ these are bug fixes (G-A3), not preferences | +| lan-profile whitelist (G-A4) | ✓ but it is a **config field**, not a toggle — same shape as `ipWhitelist` | + +The IP side needs **no new booleans**. Every gap in Part A is a correctness fix or +a new list field. + +## C.3 Target alarm: one threshold, replacing the existing boolean + +The distance ladder is **ordered** (D1 > D2 > D3). An ordered space needs a floor, +not N independent booleans — N booleans would let an operator construct incoherent +states ("warn on unrelated but not on adjacent") and would violate §9 (surface +weight mirrors necessity). + +``` +scope.alertFloor: 'excluded_only' | 'adjacent' | 'all' // default: 'adjacent' +``` + +| Value | Emits | Severity of the new rung | Use case | +|---|---|---|---| +| `excluded_only` | D1 | — | recon-heavy phase; third-party services dominate the target stream | +| `adjacent` **(default)** | D1 + D2 | `warn` | normal engagement — "right subnet/domain, wrong host" is the signal you want | +| `all` | D1 + D2 + D3 | `notice` | strict authorisation: *any* target not on the list is on the record | + +D1 is absent from every "off" position by construction — that is the fact-tier rule +made structural rather than remembered, and it is pinned by a test. + +**Migration is a two-hop chain**, both hops still running on load: +`enforcement` → `warnOnViolation` → `alertFloor`. `warnOnViolation: true` → +`'adjacent'`; `false` → `'excluded_only'`. Note `false` does **not** map to a "none" +— D1 already fired regardless of `warnOnViolation`, so the boolean was always this +floor under another name and the mapping preserves behaviour exactly. + +`'all'` is the pre-G-B3 IP path's *accidental* behaviour made into a deliberate, +named choice. Two things follow from letting D3 emit at all: + +- **It needs its own `reason`.** `unrelated` joins the closed vocabulary, at **fact** + tier: unlike D2 there is no proximity heuristic involved, just an observed + non-match against a stated list. +- **It needs its own severity step.** `notice` (grey) was added to the scale for it. + Giving D3 `warn` would put the noise G-B3 removed back *inside* the violation list; + giving it `ok` would render a recorded departure green. On the record, not shouting. + +The D3 **count** (`getUnrelatedCount()`) is kept at every floor regardless — "silent" +must stay distinguishable from "not looking", and the adherence report (G-D1) needs +the denominator. + +## C.4 The one tuning parameter (not a toggle) + +``` +scope.proximityBits: number // default 24 — container width for single-IP entries +``` + +**There is deliberately no domain-side counterpart.** The domain container is the +registrable domain — there is no coherent "expand N labels" knob, and offering one +would invite `*.co.uk`-shaped scopes. The asymmetry is intentional; record it so +nobody adds `proximityLabels` for symmetry's sake. + +`scope.publicSuffixes` is **not** that counterpart. It does not tune how far the +container reaches; it corrects *where the boundary is* for a suffix the built-in +table does not know. Width knob vs. correctness input — different kinds. + +**Net: one enum replaces one boolean, plus one integer. No new switches.** + +--- + +# Part D — Prevention is a non-goal + +## D.1 The boundary + +> **RedLog records and warns. It does not prevent, gate, or block.** + +This is not a limitation to be worked around later — it is the boundary that +defines the subsystem. Everything in Parts A–C is *the whole intervention*, and +Part D exists to say so explicitly, because "surely it should also stop me" is the +first thing everyone asks. + +`enforcement: block` was **removed** — `config.ts` migrates only `'warn' | 'log'`. +Three reasons, stronger than citing the law: + +1. **The seam is fire-and-forget by construction.** `shell/redlog-hook.zsh` + `_redlog_preexec` does fire *before* execution — the seam exists — but it posts + with `curl -sf ... &!`: backgrounded, response discarded, 1s connect timeout. To + gate, the shell would have to *wait on a localhost round-trip before every + command*, and a wedged RedLog would wedge the operator's terminal mid-engagement. +2. **Coverage is heuristic, and a 70%-coverage gate is worse than none.** + `extractTargetWithProvenance()` cannot see targets inside pipelines, loops, + scripts, or custom tooling. A gate the operator *trusts* but that silently passes + a third of traffic converts a caught mistake into an uncaught one. +3. **A blocked action leaves the weaker record.** RedLog's deliverable is "provably + did not exceed scope," not "was unable to exceed scope." The former survives a + bypassed path; the latter is refuted by one `curl`. + +## D.2 The consequence: the alert-quality bar goes *up*, not down + +Declining to block does not lower what the alerting subsystem owes — it raises it. +With no second line of defence: + +- **A false green is unrecoverable.** Nothing downstream catches what the badge got + wrong. This is why G-A1 (the A-9 false green) is the highest-severity item in the + whole subsystem, not a cosmetic issue. +- **Noise is a safety defect, not an annoyance.** A muted channel is a removed + defence. G-B3 (D3 alerting as loudly as D2) is therefore a correctness bug. +- **Latency is the only "prevention" available.** A D2 warning raised at + `command_start` reaches the operator within seconds — the drift-to-discovery + window *is* the whole mitigation. Shortening it is legitimate work; adding a gate + is not. + +## D.3 What RedLog *does* contribute to staying in scope + +Entirely within record-and-warn: + +| | Contribution | Mechanism | Status | +|---|---|---|---| +| **before** | the scope is correct and legible, sourced from the authorisation document rather than typed — **and attributable to it** | `scope.scopeFile` + `readScopeFile` provenance | ✅ shipped | +| **during** | a fast, correctly-tiered warning the operator can act on (Parts A–C) | `IPMonitor` / `ScopeMonitor` | 🟡 gaps open | +| **after** | **positive adherence proof**, not just a violation list | `scope-adherence.ts` + export | ✅ shipped | + +The *after* column used to be the underbuilt one. `data:exportViolations` proves +violations happened — the **accusation** half — and a client reading it cannot tell +three near-misses out of 250 targets from three out of five. `scope-adherence.ts` +builds the other half: **every target touched, its distance classification, and the +denominator** — "247 targets, 244 in scope, 0 excluded, 3 adjacent", with per-target +first/last seen, action counts and command samples. + +Three things make it defensible rather than merely reassuring: + +1. **It re-classifies from the event stream**, not from the alert log, so D0 targets + — the ones that never fired anything — are counted. Those are the proof. +2. **Recorded violations travel alongside**, exactly as they were chained at the + time, under whatever scope was in force then. +3. **Re-classification uses the *current* scope, and the report says so.** Any + `config_changed` that touched a `scope.*` key is listed, and any target whose + live classification disagrees with what was recorded is called out. A scope + edited mid-engagement is a caveat stated out loud, not a silent error. + +**Scope provenance (G-D2).** The report states "judged against this scope", and +`readScopeFile` now makes that scope attributable: a **sha256 of the file bytes**, +the entry count, its mtime, and when RedLog read it. A chained `scope_loaded` event +records the same at the two *authoritative* load points — project open and config +save — deduped on digest so reopening a project does not fill the timeline with +identical rows. The read-only re-reads that build an export deliberately do **not** +emit: an export must not manufacture history. + +The digest is the join between the report and the authorisation document a reviewer +was handed. Without it, "the scope was `/engagements/acme/scope.txt`" is a claim +taken on trust. + +It also closes a silent failure: a scope file that parses to **zero entries** +contributes no targets, and "scope active" read exactly like a correctly loaded one. +The entry count makes that visible, and the panel says so. + +**In the signed bundle too.** Alongside the standalone export +(`data:exportAdherence`) and the live summary in Scope & Evidence, the report ships +as `scope-adherence.json` inside the evidence bundle — a hashed entry in +`manifest.files`, so it inherits the manifest sha256 and the HMAC and travels signed +with the rest of the evidence. A loose JSON file claiming "244 of 247 in scope" +proves nothing; the same file under the bundle's integrity does. The manifest also +carries the headline (`scopeAdherence`), so a reviewer reading only `manifest.json` +sees the claim, and the bundle README tells them how to check the scope digest +against the document they issued. + +**The invariant that keeps it honest:** the report is built from the rows **as +written to the bundle**, after the layer-4 sanitize swap — never from the raw DB. A +`client-deliverable` bundle sanitizes out-of-scope bodies, and a report built from +raw rows would have been a side channel straight around that gate. A property test +asserts every command sample in the report also appears in `events.jsonl`, so the +guarantee survives future changes to the sanitize rules. + +Both profiles get it: an internal bundle that cannot state what it stayed inside of +is missing the same proof. No scope configured means **no file and a null claim** — +an empty report would read as "nothing was out of bounds". + +## D.4 Explicit non-goals (considered, rejected — do not re-propose) + +| Non-goal | Why not | +|---|---| +| Blocking / gating command execution | D.1 — all three reasons | +| Confirmation prompts before a D1 target, even in an opt-in wrapper | Friction is prevention wearing a costume; it also trains reflex-confirmation, which degrades D1's meaning | +| Exporting scope into tool-enforceable formats (nmap `--excludefile`, Burp scope, deny-lists) | Legitimate work, but it is *prevention tooling*, not record-and-warn. Out of RedLog's remit. | +| Proxy/DNS-level interception of out-of-scope traffic | Same, plus it makes RedLog a network component with an availability contract it must not have | + +## D.5 Additional gaps + +| # | Gap | Kind | Notes | +|---|---|---|---| +| **G-C3** | ~~`scope.alertFloor` enum replacing `warnOnViolation`, + `proximityBits`~~ | ✅ **fixed** | Three-value floor + two-hop migration + Settings selector. `'all'` needed a `unrelated` reason (fact tier) and a `notice` severity step. `proximityBits` landed with G-B1. | +| **G-D1** | ~~Scope-adherence report (positive proof, not just violations)~~ | ✅ **fixed** | `scope-adherence.ts` + loose export + live summary + `scope-adherence.json` in the signed bundle with a `scopeAdherence` manifest headline. Built from post-sanitize rows, so it cannot bypass the client-deliverable gate. | +| **G-D2** | ~~Scope provenance — which file, loaded when, by whom, with what diff~~ | ✅ **fixed** | `readScopeFile` returns a sha256 + entry count + mtime; a chained `scope_loaded` event records it at the two authoritative load points; the adherence report embeds it. Also catches a scope file that parses to zero. | diff --git a/docs/CAPTURE-SOURCE-TAXONOMY.md b/docs/CAPTURE-SOURCE-TAXONOMY.md new file mode 100644 index 00000000..610182b7 --- /dev/null +++ b/docs/CAPTURE-SOURCE-TAXONOMY.md @@ -0,0 +1,193 @@ +# Capture Source Taxonomy — built-in vs plugin, AI-era + +Written 2026-08-13. Companion to `SPEC-SCOPE-AWARE-LIFECYCLE.md`. Answers three +questions before the lifecycle work: (1) **what capture items must ship built-in**, +(2) **what the AI era adds to the pentest/red-team tool surface** and whether +RedLog covers it, (3) **where the built-in/plugin line sits** and why the plugin +interface is general enough for the long tail. Everything here is constrained by +one fact: **RedLog runs on the operator's own laptop**, so every capture item is +judged on CPU, disk, IO, and install/config friction, not just coverage. + +## The organizing lens (DESIGN-PRINCIPLES §2) + +Capture items split by **who initiates the bytes**, which decides default-on vs +opt-in and the exposure profile: + +- **Operator-deliberate** (default-on): commands, tool-calls, navigation, targets + — the operator's own actions. Bounded exposure (fires per-action). +- **Ambient vacuum** (opt-in): clipboard, periodic screenshots — run continuously, + carry a standing exposure window, so the operator consciously accepts the bet. + +The key design move for the AI era: capture the **substrate** (command / packet / +tool-call / screen), not the specific tool. A substrate-level item covers a whole +class of tools; only a tool with its own out-of-band channel needs a dedicated +plugin (see Part 4). + +## Part 1 — Built-in capture inventory (17 `agent_type`s) + +| # | agent_type | What it captures | Heavy artifact | Local cost | Install / config | +|---|---|---|---|---|---| +| 1 | `shell`/`terminal` | commands (hook or built-in PTY) + output | **`.cast` recording** | disk hog; PTY cheap | shell-source into `.zshrc`, or built-in terminal | +| 2 | `http` | mitmproxy request/response | **io body (sidecar)** | TLS intercept per-flow; dedup+2MB cap | mitmproxy addon + **CA cert trust** (per-OS) | +| 3 | `http_navigation` | page loads in CDP browser | io body | CDP session | launch proxied browser | +| 4 | `browser` | proxied browser capture/nav | io body | as above | as above | +| 5 | `scanner` | mitmproxy / port / vuln scan output (open type) | io body | varies by tool | via API / addon | +| 6 | `dns` | DNS queries | — | light | resolver hook / passive | +| 7 | `agent` | **AI agent tool-calls** (Claude Code `PostToolUse`, etc.) | transcript | tailer light | `claude-settings` hook (Tier A) | +| 8 | `process` | process spawn/exit (ps polling) | — | **polling CPU** | macOS/Linux ps monitor, opt-in | +| 9 | `file_transfer` | ingress/exfil, auto-detected from shell | — | free (rides shell) | none (auto) | +| 10 | `pivot` | tunnels/SOCKS, auto-detected from shell | — | free | none (auto) | +| 11 | `cleanup` | anti-forensics, auto-detected from shell | — | free | none (auto) | +| 12 | `loot` | detected secrets (derived) | — | regex per-event | none (built-in detector) | +| 13 | `marker` | operator finding notes | — | free | operator UI | +| 14 | `screenshot` | desktop capture (periodic + on-demand) | **`.jpg` files** | **capture+encode CPU, disk hog** | opt-in periodic; on-demand free | +| 15 | `clipboard` | clipboard changes (sampled) | — | **poll interval CPU** | **default OFF** (highly sensitive) | +| 16 | `system` | scope_violation / sanitized / *_pruned / pause | — | free (meta) | n/a | +| 17 | *(watchPaths)* `file_transfer` via fs watch | filesystem drops in watched dirs | — | fs-watch cost | needs `watchPaths` list | + +**Substrate coverage:** items 1–8 + 14–15 are tool-agnostic — they record the +*medium* (shell, HTTP, DNS, tool-call, screen, clipboard, process) regardless of +which specific tool produced it. Items 9–12 are auto-detections layered on the +shell substrate. + +## Part 2 — AI-era tool surface → capture mapping + +The 2026 tool stack (per web research) mapped to what it *emits* and which +built-in item captures it. "Gap → plugin" marks the long tail. + +| Phase | Common tools (2026) | Emits | Built-in covers | Gap → plugin | +|---|---|---|---|---| +| Recon | Nmap, Shodan, theHarvester, subfinder/httpx/katana, Maltego | shell cmd + output; DNS/HTTP | `shell`+`dns`+`http`, target extract | structured **nmap XML / nuclei JSON** parsing | +| Vuln scan | Nessus, OpenVAS, Nikto, nuclei | shell/scanner output | `shell`/`scanner` | structured findings ingest | +| Web/exploit | Burp Suite Pro, SQLMap, Metasploit | HTTP; interactive consoles | `http` (via proxy), `shell` for SQLMap | **Burp proxy overlap, msfconsole RPC** | +| C2 | Cobalt Strike, Sliver, Mythic | beacon check-ins, own logs | — (out-of-band channel) | **C2 log ingest** (Sliver log / CS Aggressor) | +| Post-ex | BloodHound, Mimikatz, LinPEAS/WinPEAS, Empire | shell cmd; graph data | `shell`+`loot` | **BloodHound graph ingest** | +| **AI-agent** | **PentestGPT, Nebula, hackingBuddyGPT, CAI** | tool-calls; some shell-out | `agent` (native hook) / `shell` (fallback) | tool-specific **native hooks** for richer attribution | +| **AI-MCP** | **HexStrike, PentestMCP** (MCP servers) | MCP tool-calls | `agent` tool-call capture | **🔴 `mcpTools`** for RedLog-as-tool | +| **AI-target** | **DeepTeam, LLM/Agentic red teaming** (OWASP LLM/Agentic Top 10, MITRE ATLAS) | HTTP API calls to target LLM; injection payloads | `http` + `agent` | **semantic labels** (prompt-injection / tool-call-hijack) as `eventType`/loot-pattern | + +### The AI-era shift (the load-bearing finding) + +The capture surface moved from **"commands + packets"** to also include **agent +tool-calls, MCP server interactions, and LLM API exchanges**. RedLog is +architecturally ready — it already has `agent` capture, the agent-hook framework +(Tier A native / Tier C shell fallback, `docs/plugin-development.md`), and 🔴 +`mcpTools`. What the *common built-in* set must explicitly own: + +1. **AI-agent tool-call capture** — have it (`agent` + `PostToolUse` hook). +2. **MCP tool-call visibility** — the operator's AI running MCP security tools + (HexStrike, PentestMCP) is now a primary evidence stream; its tool-calls should + land like any other action. Partly via `mcpTools`, but capturing *another* + MCP server's calls is plugin territory. +3. **LLM-target exchanges** — testing an AI *target* is captured as `http` + (API calls) + `agent` (attacker-LLM transcript); the injection-attempt + *labelling* is a `eventType`/loot-pattern contribution, not a new source. + +Everything tool-specific (Burp, msfconsole, Sliver/CS, BloodHound, per-tool AI +hooks, structured scan parsers) is correctly **long tail → plugin**. + +## Part 3 — Local-execution constraints (perf / space / install) + +Grouped by the real bottleneck on a laptop: + +- **Disk hogs (space):** `.cast` (terminal), `.jpg` (periodic screenshots), io + bodies. → governed by `SPEC-SCOPE-AWARE-LIFECYCLE.md` (compress→tier→prune, + size+age triggers). Screenshots/`.cast` are Tier-3 (size/time only), the io + bodies are Tier-1 (scope-prioritized). Dedup + 2 MB ceiling already cap bodies. +- **CPU (perf):** process ps-polling, clipboard polling, mitmproxy TLS intercept, + screenshot encode. → interval-tunable + opt-in per §2; the two continuous + vacuums (clipboard, periodic screenshot) are opt-in precisely for this. +- **Install friction (setup):** the hard ones are **mitmproxy CA cert trust** + (per-OS, per-browser), **shell hook into `.zshrc`**, **`claude-settings` hook**, + **CDP browser launch**. Everything else is passive/auto. → `installMethod` + (`shell-source` / `claude-settings` / `manual`) + `verify` command must make + each self-describing (see Part 4). + +## Part 4 — The built-in / plugin boundary + interface generality + +**Ships built-in (common, substrate-level, tool-agnostic):** items 1–15 above + +auto-detections. Rule: *if it captures a medium (shell/HTTP/DNS/tool-call/screen/ +clipboard/process) rather than parsing one tool's proprietary output, it's +built-in.* + +**Plugin (long tail, tool-specific or personal):** parsing a specific tool's +output/console/DB, or a bespoke in-house tool. Grounded in the existing +🟢/🔴 trust model (`redlog-plugin-system`): + +| Need | Contribution | Tier | Runs code? | +|---|---|---|---| +| Recognize a new tool's targets | `targetExtractors` | 🟢 | no | +| Stamp fields on its shell events | `commandTags` | 🟢 | no | +| Give its events a timeline identity | `eventTypes` | 🟢 | no | +| Teach loot detector its secret format | `lootPatterns` | 🟢 | no | +| Add allow/deny redaction | `redaction` | 🟢 | no | +| Capture an out-of-band channel (C2 log, Burp, BloodHound) | `capture` script → local API | 🟢 | no (out-of-process, unprivileged) | +| Let an AI agent operate RedLog | `mcpTools` | 🔴 | yes (isolated utilityProcess, capability-scoped `ctx`, hash-pinned consent) | + +**Why the interface is general enough for the long tail:** because built-in +capture is *substrate-level*, a new tool usually needs only a 🟢 `targetExtractor` ++ `eventType` — a few lines of **data**, no code, loaded automatically, unable to +touch the evidence chain. A full `capture` script (still 🟢, out-of-process, +POSTs to the authenticated local API) is needed only when the tool has its own +channel RedLog isn't already sitting on (a C2 beacon, Burp's proxy, BloodHound's +graph DB). Only code that runs *inside* RedLog for an AI agent is 🔴 — isolated, +capability-scoped, human-approved. So: **common = built-in substrate; rare/personal += 🟢 data or out-of-process script; agent-operable = 🔴 isolated.** The trust cost +scales with the actual risk, and the long tail lands almost entirely in trust-free +🟢. + +**Plugin install generality:** every `capture` contribution declares its own +`installMethod` (`shell-source` / `claude-settings` / `manual`), `manualSteps` +(with the `{hookFile}` placeholder RedLog resolves to an absolute path), and a +`verify` command — so the long tail is self-describing about setup even without a +one-click path, matching the local-install reality of Part 3. + +### The install/enable separation (four gates, not two) + +The lifecycle has **four separate, differently-consequential, independently +reversible** states — the design deliberately splits *enablement inside RedLog* +from *installing a hook into the operator's environment*: + +| Gate | State | Where it lives | Blast radius | Reversibility | +|---|---|---|---|---| +| 1 | **Present** | folder in `~/.redlog/plugins//` or bundled | files only | delete folder | +| 2 | **Enabled** | `state.json` `disabled[]`; Settings ▸ Enable/Disable | in-RedLog process; contributions merged | flip a flag, zero external footprint | +| 3 | **Trusted** (🔴 only) | `trust.json` content-hash + capability pin | privileged code may run | revoke consent | +| 4 | **Capture hook installed** | `.zshrc` / `~/.claude/settings.json` via `installMethod`; tracked by `capture-health` `hookInstalled` | **mutates files OUTSIDE RedLog that persist when it's closed** | separate uninstall | + +**Why gates 2 and 4 must be separate:** enabling merges data into RedLog's own +process — cheap, reversible, no footprint outside the app. Installing a capture +hook mutates *external* system state (`.zshrc`, `~/.claude/settings.json`) that +outlives RedLog's process, so it needs its own consent and its own uninstall. +Conflating them forces a bad choice: either enabling silently edits your shell +config (unrequested footprint), or disabling can't clean up because it never +tracked what it wrote. The split lets the operator vet and enable a plugin safely +in-process **first**, then take the consequential environment-wiring step +deliberately. + +**Gotcha to preserve — Disable ≠ Uninstall.** `setPluginEnabled(false)` +(`plugins/index.ts`) only flips `state.json`; it does **not** remove the hook +line from `.zshrc`. Hook uninstall must be its own UI action, or orphaned hook +lines accumulate in the operator's shell config (and keep firing on every shell, +whether or not RedLog is open). Gate 4 (hook install) *is* the "install friction" +of Part 3 (mitmproxy cert, `.zshrc`, `claude-settings`, CDP browser) — isolating +it as one deliberate, separately-reversible step is what makes the local-install +reality manageable. + +## Recommendation + +- **Ship built-in:** the 15 substrate items + auto-detections. Add nothing + tool-specific to core. +- **AI-era, promote to first-class built-in emphasis:** `agent` tool-call capture + and MCP tool-call visibility — this is where 2026 evidence increasingly lives. +- **Everything else → plugin**, and the 🟢-first interface already makes the long + tail cheap (data/script, trust-free) with 🔴 reserved for agent-operable code. +- **Local cost is handled by the lifecycle spec**, not by dropping capture: disk + hogs (`.cast`/screenshots/bodies) rotate; continuous vacuums stay opt-in. + +## Sources + +- Pentest stack 2026 — [PlexTrac](https://plextrac.com/the-most-popular-penetration-testing-tools-this-year/), [Axis Intelligence](https://axis-intelligence.com/best-penetration-testing-tools-tested-guide/), [HackerDNA](https://hackerdna.com/blog/penetration-testing-tools) +- AI red-team frameworks — [Synack](https://www.synack.com/blog/best-ai-red-teaming-tools/), [Straiker](https://www.straiker.ai/blog/top-6-ai-red-teaming-and-adversarial-testing-tools) +- AI pentest agents — [Ostorlab](https://blog.ostorlab.co/8-open-source-ai-pentest-tools-2026.html), [awesome-ai-pentesting](https://github.com/skyvanguard/awesome-ai-pentesting), [appsecsanta](https://appsecsanta.com/research/ai-pentesting-agents-2026) +- Operator logging / evidence — [Red Team Guide — Operator Log](https://redteam.guide/docs/Templates/oplog/), [Hacking Articles](https://www.hackingarticles.in/guide-to-red-team-operations/) diff --git a/docs/CONTROL-PLANE-FACES.md b/docs/CONTROL-PLANE-FACES.md new file mode 100644 index 00000000..3445f49d --- /dev/null +++ b/docs/CONTROL-PLANE-FACES.md @@ -0,0 +1,95 @@ +# Control-Plane Faces — one implementation, many thin adapters + +Written 2026-08-13. Applies `DECOMPOSITION-METHOD.md` to RedLog's control plane +(DESIGN-PRINCIPLES §7). This subsystem decomposes **differently** from plugins and +detectors, and that difference is itself instructive: every face does the *same* +shape of work (adapt the canonical op set to a transport), so the closed set that +matters is not "N roles" but **the canonical op catalog + a single Adapter role**. + +## The method, adapted + +For plugins/detectors the mechanism axis produced *several* roles (different shapes +of work). Here every face is structurally identical — a thin adapter. So: + +- **The closed set = the canonical op catalog** (the evidence-relevant control ops). +- **The single role = Adapter** (project a subset of ops onto a transport, adding + only serialization + auth, never logic). +- **Faces are then *classified*** along transport × audience × generation — a + catalog, not a role zoo. + +This is the "one role + canonical catalog" variant of the method (see +`DECOMPOSITION-METHOD.md`). + +## The canonical layer (what everything adapts) + +§7 names "the localhost REST handlers" as canonical, but structurally the true +canonical implementation is **the core op modules** (`insertEvent`, `searchEvents`, +the chain ops, scope/loot reads). **REST is itself an adapter** over those — the +first and most privileged one, but not the source of truth. Naming the core (not +REST) as canonical is what lets MCP, IPC, CLI, and the Codex schema all be *peer* +adapters that cannot drift from each other, because none is defined in terms of +another. + +## The canonical op catalog (closed, §7 "evidence-relevant only") + +Grouped by §7's three purposes: + +| Group | Ops | Source | +|---|---|---| +| **Write evidence** | `mark`, `quickmark`, `log_event` | `api-server` L125/L136/L169 | +| **Operate chain / recording** | `chain_status`, `chain_anchor_now`, `chain_verify`, `chain_upgrade`, `recording` (pause/resume) | L199–208, L191 | +| **Read for decisions** | `status`, `whoami`, `operators_list`, `scope`, `search`, `events`, `config`, `loot_scan`, `screenshot`, `quickmarks_list` | L111–185 | + +**Boundary (§7):** the catalog is *evidence-relevant control* only. It **never +duplicates capture** (hooks own that) and **never grows evidence-irrelevant ops**. +Delivery ops (`export/bundle`, `sanitize`, `deconfliction`, `anchors`) that appear +on REST belong to the **Delivery** subsystem (next candidate), not the control +catalog — see Gaps. + +## The Adapter role (the contract every face fills) + +- **Declares:** transport, audience, the op-subset it exposes, auth model, + serialization. +- **Contains:** no logic. It marshals a request into a canonical op call and + marshals the result back. **A face that makes a decision is a bug** — that is the + §7 failure mode, and the review test for any face change. +- **Test seam:** given a mocked canonical op layer, assert the adapter calls the + right op with the right args and serializes the result — no behaviour of its own + to test beyond marshalling. + +## Face classification (transport × audience × generation) + +| Face | Transport | Audience | Generation | Op coverage | Source | +|---|---|---|---|---|---| +| **REST** | HTTP `/api/*` | scripts / CLI backend | canonical hand-written | full | `src/core/api-server.ts` | +| **MCP** | JSON-RPC over HTTP `/mcp` + stdio bridge | AI agents | mirrored (18 `redlog_*` tools) | full control catalog | `api-server` L111–208, `mcp-tools.ts` | +| **Renderer IPC** | Electron IPC (`window.redlog`) | operator UI | native | UI-relevant subset | `src/main` ↔ `src/preload` | +| **CLI** | shells out to REST | humans / scripts | thin wrapper | partial (events, marker, search) | `cli/redlog-cli.js` | +| **Shell helpers** | source into `.zshrc`; curl REST | human shell | thin wrapper | quick ops | `shell/redlog-agent.sh` | +| **Codex schema** | static JSON function schema | external GPT/codegen | mirrors MCP (18) | full control catalog | `docs/codex-tools.json` | + +## Gaps this framework surfaces + +| # | Gap | Why it matters | Fix | +|---|---|---|---| +| 1 | Is MCP a thin adapter over the *same* canonical ops as REST, or a **parallel `switch`**? | If parallel, the two can drift — the exact §7 risk | verify both dispatch into one shared op layer (the core modules); if not, extract it | +| 2 | `codex-tools.json` is hand-maintained but must equal the MCP surface | §7 wants faces "ideally generated" — hand-sync drifts | **generate** the Codex schema from the MCP tool registry | +| 3 | REST mixes control ops with **delivery ops** (`export/bundle`, `sanitize`, `deconfliction`, `anchors`) | blurs the §7 "evidence-relevant control only" boundary | move delivery ops to the Delivery subsystem's face; keep the control catalog minimal | +| 4 | CLI covers only a subset of ops | fine if intentional, opaque if not | declare the CLI's op-subset explicitly (part of the Adapter contract) | +| 5 | No single place lists the canonical catalog | new faces guess the op set | this doc's catalog table is the source; keep it in sync with the tool registry | + +## Why this decomposition pays + +- **Adding an integration surface** (a new agent client, a new script transport) + becomes "fill the Adapter contract for these ops on this transport" — no design. +- **Drift becomes detectable:** any face exposing an op not in the catalog, or + containing logic, fails the contract on review. +- **§7 stops being a slogan:** "one implementation, many thin faces" is now a + catalog + a contract you can check a diff against. + +## Cross-references + +- The method + variants: `DECOMPOSITION-METHOD.md` +- The principle: `DESIGN-PRINCIPLES.md` §7 +- The full agent surface (transports, setup): `agent-integration.md` +- Next candidate that owns the delivery ops split out here: Delivery / export targets diff --git a/docs/DECOMPOSITION-BACKLOG.md b/docs/DECOMPOSITION-BACKLOG.md new file mode 100644 index 00000000..b810da54 --- /dev/null +++ b/docs/DECOMPOSITION-BACKLOG.md @@ -0,0 +1,146 @@ +# Decomposition Backlog — the gaps the framework suite surfaced + +Written 2026-08-13. Consolidates the "Gaps" tables scattered across the nine +decomposition docs (`DECOMPOSITION-METHOD.md` names them all) into one prioritized, +**deduplicated** backlog. The dedup matters: because the frameworks interlock, many +gaps are *the same underlying work seen from different docs* — so ~30 gap rows +collapse into a handful of keystones. Each item notes: what, which docs surfaced +it, code vs doc/example, what it unblocks / depends on, and whether it touches +another session's active territory (coordinate before editing). + +> **Status (updated 2026-08-14):** no longer accurate as written — parts of K1 and +> K2 have shipped (see the ✅ marks below and `git log`). Treat this as a planning +> doc, **not** a shipped-state tracker: verify against code before relying on any +> row. Items marked +> ⚠️ touch files other sessions are actively editing (`Timeline.tsx`, +> `Settings.tsx`, `retention.ts`, `plugins/types.ts`) — coordinate first. + +## Keystones (do first — each unblocks several other gaps) + +### K1 — The §3 inferred-`detection` primitive ⚠️ (plugins/types.ts, renderer) +The single highest-leverage item. Surfaced **four times**: `PLUGIN-ROLES` gap #3, +`DETECTOR-ROLES` gap #3, `EVENT-TYPE-VOCABULARY` gap #2, `TIMELINE-ELEMENTS` G4. +- Define a shared **inferred-`detection` event shape** — confidence, detector + attribution, one-click promote-to-marker. **Still open.** Note `authority` and + `confidence` turned out to be **orthogonal axes**, not one field: authority is + two-valued and decides rendering + forwarding; confidence grades an inference + and is meaningless on a fact. `plugins/types.ts` already exports `Confidence`; + `core/authority.ts` owns the other axis. Do not merge them. +- ✅ **Add `authority: 'fact' | 'inferred'` to `EventTypeDef`** — done, plus + `EventTypeContribution.authority` for plugins, a built-in default table, and + per-event override precedence (`core/authority.ts`). `insertEvent` stamps + `inferred` into the **hashed** row, so the label cannot be stripped without + breaking the chain; absence means `fact`. +- 🟡 **Solid-vs-dashed wired to `authority`** — done for timeline event dots + (`lib/dotShape.ts`) and already done for the phase ribbon. Other timeline + elements and the one-click promote flow beyond phase are still open. +- **Unblocks:** plugin Labeller role, semantic injection labels (K2/K3), timeline + honesty (G4), detector suggestions rendering as promotable. **Code.** + +### K2 — Off-chain content unification ⚠️ (retention.ts, new store module) +Surfaced across `OFF-CHAIN-CONTENT-STORES` (gaps 1–6) + `SPEC-SCOPE-AWARE-LIFECYCLE` +(G1–G2). ~~The keystone inside it: **io_ref is spec-only, never implemented**.~~ (No longer true — `src/core/io-store.ts` shipped; re-scope this item against code.) +- Implement the **io_ref Blob store** (the keystone — no runtime code exists today). +- Wire an **io keep-window + size cap** into `config.ts` (lifecycle G1). +- Collapse the **three parallel retention branches** (`terminal.castKeepDays` + + `screenshots.keepDays` + `agentTranscripts.keepDays`) into **one loop over a + `contentStore.` registry** (off-chain gap #2 / retention). +- Extract **one shared `isInsideDir()` traversal guard** (3 copies today) + one + shared "bytes-match-digest / pruned≠tampered" verify. +- Route **agent-transcripts** through the Stream adapter. +- **Unblocks:** the entire lifecycle spec (compress/prune/pin needs a real store), + scope-aware retention. **Code.** + +## Quick wins (🟢 zero core change — immediate value) + +### Q1 — `scan-parsers` plugin pack +`SPEC-AI-ERA-PLUGINS` gap 1 / `PLUGIN-ROLES` Parser. Bundled 🟢 pack: parse nmap +`-oX` / nuclei `-json` → typed `scanner`/`scan_result` events + `eventTypes` + +`targetExtractors`. No core change. **Doc/plugin.** + +### Q2 — `mcp-tee` example + shim +`SPEC-AI-ERA-PLUGINS` gap 3 / `PLUGIN-ROLES` Tee. A stdio/HTTP-MCP proxy that +forwards to a third-party MCP server and POSTs each call. No new API — needs +`examples/plugins/mcp-tee`. **Example.** + +### Q3 — pair-emit lint ⚠️ (Settings.tsx, plugin loader) +`EVENT-TYPE-VOCABULARY` gap 1. A capture plugin emitting a new `agent_type` without +a paired `eventTypes` should **warn at load** and surface the undeclared kind in +Settings ▸ Plugins (else it lands in the "other" lane). **Code (small).** + +## Unification / hygiene (real convergence, medium effort) + +### U1 — Scope-aware sanitize planner +`SPEC-SCOPE-AWARE-LIFECYCLE` G3 + `DELIVERY-TARGETS` gap 5. Scope planner + `unknown` +bucket in front of `sanitize.ts`; becomes the third-party Snapshot's default +profile. Depends on scope verdict reuse (`ScopeMonitor`). **Code.** + +### U2 — Named sanitize profiles across delivery +`DELIVERY-TARGETS` gap 1. Unify the three ad-hoc sanitize paths (bundle layer-4 / +cloud-share `RedactionPreview` / deconfliction subtype-filter) into named profiles +`full` / `scope-sanitized` / `filtered`, shared by all delivery targets. Overlaps U1. +**Code.** + +### U3 — Control-plane face hygiene +`CONTROL-PLANE-FACES` gaps 1–5 + `DELIVERY-TARGETS` gap 2. +- Verify MCP dispatches into the **same canonical ops** as REST (not a parallel + `switch`) — the core §7 drift risk. +- **Generate** `codex-tools.json` from the MCP tool registry (stop hand-syncing). +- Move delivery ops (`export/bundle`, `sanitize`, `deconfliction`, cloud-share) + **off** the §7 control catalog onto a Delivery face. +- Declare the CLI's op-subset explicitly. **Code.** + +### U4 — Cloud-share integrity parity +`DELIVERY-TARGETS` gap 3. Verify cloud-share carries the same Ed25519 + OTS as +`bundle-export`; make "verifiable snapshot" uniform, not per-transport. **Code (verify + maybe fix).** + +## Contribution-surface growth (unblock plugin roles) + +### C1 — Implement `monitors` (🔴, reserved) +`PLUGIN-ROLES` gap 1 / `SPEC-AI-ERA-PLUGINS` gap 4. Isolated 🔴 that reads the event +stream + appends inferred detections. **Depends on K1.** Unblocks semantic injection +labels + plugin Monitor path. **Code.** + +### C2 — Add `detectionPatterns` (🟢) +`PLUGIN-ROLES` gap 4 / `DETECTOR-ROLES` gap 2. A 🟢 sibling of `lootPatterns` (or a +`kind` field) emitting inferred detections for the regex-detectable injection +subset. **Depends on K1.** **Code.** + +### C3 — `exporters` (🔴, reserved) + plugin Stream +`PLUGIN-ROLES` gap (Exporter) / `DELIVERY-TARGETS` gap 4. Implement `exporters` (first +instance = scope-sanitized bundle, U1); add a gated plugin Stream for SIEM/blue-team +forwarders. **Code.** + +### C4 — User-plugin `tailers` isolation +`PLUGIN-ROLES` Tailer (bundled-only today) / `CAPTURE-SOURCE-TAXONOMY`. Land the +v0.8.3+ isolation so user-authored tailers (e.g. C2 log packs) work, not just +bundled. Unblocks Q's `c2-tailers` as a user pack. **Code.** + +## Small / polish + +- **S1** Extractors state an explicit `confidence` field (`DETECTOR-ROLES` gap 1) — + §3 "this is inferred" visible, not assumed. **Code (small).** +- **S2** Generalize the Correlator (`DETECTOR-ROLES` gap 5) — declare linker-field + + relation to enable pivot→command / loot→exfil chains. **Code.** + +## Already ticketed / owned elsewhere (cross-ref, don't duplicate) + +- **Timeline UX** `TIMELINE-ELEMENTS` G1–G3, G5 → existing **T1/T2/T3** tickets + + the §8 axis re-bind (redlog-91's active `SPEC-TIMELINE-AXIS` work). G4 = K1 above. +- **Plugin lifecycle UI** → **PL1** (`UX-BACKLOG-TICKETS.md`). +- **Instance-ordinal visual** (`TIMELINE-ELEMENTS` instance channel, `[proposed]`) → + the disputed uncommitted 159-line `Timeline.tsx` change; **authorship unresolved**, + do not touch until the owner commits it. + +## Suggested order + +1. **K1** (unblocks the most, and makes the timeline honest). +2. **Q1 / Q2** in parallel (zero-core, ship value immediately). +3. **K2** (unblocks the whole lifecycle). +4. **U1/U2** then **C1/C2** (need K1) ; **U3/U4** independently. +5. **C3/C4**, then **S1/S2** polish. + +## Cross-references + +- Where each gap came from: the nine docs indexed in `DECOMPOSITION-METHOD.md` +- Existing tickets: `UX-BACKLOG-TICKETS.md` diff --git a/docs/DECOMPOSITION-METHOD.md b/docs/DECOMPOSITION-METHOD.md new file mode 100644 index 00000000..925d594b --- /dev/null +++ b/docs/DECOMPOSITION-METHOD.md @@ -0,0 +1,88 @@ +# The Decomposition Method — how RedLog closes open subsystems + +Written 2026-08-13. RedLog has several subsystems that grow over time (plugins, +capture sources, detectors, integration surfaces). Left ad-hoc, each addition is a +bespoke design decision and the subsystem drifts. This doc names the **repeatable +method** already used for plugins (`PLUGIN-ROLES.md`) and capture +(`CAPTURE-SOURCE-TAXONOMY.md`) so any future subsystem — plugin or not — can be +decomposed the same way, and lists which RedLog subsystems are next. + +## The method (six steps) + +1. **Name the open-ended subsystem.** The tell: things get *added* to it over time + (new plugin, new detector, new integration), and today each addition is a + one-off. If it's a fixed list that never grows, it doesn't need this. +2. **Find the mechanism axis** — *what shape of work is this?* Usually + "what layer does it act on" or "what is its input → output shape." This axis + produces the **roles**. +3. **Find the tier / authority axis** — the orthogonal concern that changes the + *rules* not the *shape*: trust (runs code inside RedLog?), authority (§3: + authoritative fact vs inferred suggestion?), or origin (built-in vs plugin). +4. **Enumerate the closed role set** at the axes' intersection, then **prove it's + exhaustive** over the actual API/surface — a table mapping every existing slot + (every `PluginContributes` field, every `*-detector.ts`) to exactly one role. + The proof is what turns "a taxonomy" into "the taxonomy." + + **Variant — one role + a canonical catalog.** Sometimes every member does the + *same* shape of work (e.g. control-plane faces are all thin adapters). Then the + mechanism axis yields a *single* role, and the closed set that matters is a + **canonical catalog** the role adapts (the op set). You still prove + exhaustiveness — every face exposes only catalog ops; anything outside is drift. + See `CONTROL-PLANE-FACES.md`. +5. **Give each role a template** — the same headings for every role so building a + new one is filling a known shape: contract (what it emits), tier & why, local + cost, install/wiring, test seam, and a real instance. +6. **List the API gaps the framework surfaces** — needs that fit no role are not + "unclassifiable," they are places the *surface* must grow. Naming them turns + unknowns into a backlog. + +## What makes a decomposition *good* (the invariants) + +- **Closed:** a small, named set — not "here are some examples." +- **Exhaustive with proof:** a mapping table showing nothing falls outside. If you + can't build the table, the axes are wrong — pick different ones. +- **One axis per concern:** mechanism decides the role; tier decides the rules. + Mixing them (a role that's half-shape, half-trust) is the usual failure. +- **Template per role:** identical headings, so the docs are diff-able and a new + contributor fills a form, not a blank page. +- **Gaps named, not hidden:** every "doesn't fit yet" is an explicit API-gap row. + +## Why it pays + +- **Design:** "should we build X?" becomes "which role is X?" — and if none, "which + gap does X open?" Both have answers; the ambiguous middle disappears. +- **Build:** each role has a test seam and a contract, so implementation is + mechanical and reviewable against a template. +- **AI-navigability:** a coding agent (or a new dev) routes a task to a role in one + lookup instead of reading the whole subsystem. +- **Laws enforced structurally:** when every detector role must emit a §3 + suggestion, honesty stops depending on each author remembering §3 — the role + contract carries it. + +## RedLog decomposition map + +| Subsystem | Status | Mechanism axis (→ roles) | Tier / authority axis | Doc | +|---|---|---|---|---| +| **Plugins** | ✅ done | what layer × what shape → 7 roles | runs code inside RedLog (🟢/🔴) | `PLUGIN-ROLES.md` | +| **Capture sources** | ✅ done | which medium → 15 substrate items | operator-deliberate vs ambient (§2); built-in vs plugin | `CAPTURE-SOURCE-TAXONOMY.md` | +| **Sanitize/retention/rotation** | ✅ specced | which lifecycle stage → hot/warm/cold | pinned vs unpinned (scope/marker); chain vs artifact | `SPEC-SCOPE-AWARE-LIFECYCLE.md` | +| **Detectors / inference** | 🟡 next (this batch) | input→output shape → Extractor/Classifier/Monitor/Correlator | all §3-inferred; built-in vs plugin | `DETECTOR-ROLES.md` | +| **Control-plane faces (§7)** | ✅ done | *one role* (Adapter) + canonical op catalog; classified by transport × audience × generation | which core ops exposed; auth level | `CONTROL-PLANE-FACES.md` | +| **Delivery / export targets** | ✅ done | timing/cardinality → Snapshot vs Stream | sanitize profile by audience (full / scope-sanitized / filtered) | `DELIVERY-TARGETS.md` | +| **Off-chain content stores** | ✅ done | content shape → Blob (io_ref, screenshot) vs Stream (`.cast`) | on-chain digest vs off-chain bytes; content- vs path-addressed | `OFF-CHAIN-CONTENT-STORES.md` | +| **Timeline surface** | ✅ done (structural lens) | 6 encoding channels × 4 interaction modes | fact vs inferred suggestion (§3, solid/dashed) | `TIMELINE-ELEMENTS.md` (defers to `SPEC-TIMELINE-AXIS.md` for design) | +| **Alerting (IP + scope)** | 🟡 decomposed (code gaps open) | signal shape → Self alarm (state) vs Target alarm (event) | fact / inferred / unknown (§3); distance ladder D0–D3 | `ALERT-ROLES.md` | +| **Event-type vocabulary** | ✅ done | origin → authored/captured/agent/derived/system/plugin | authoritative vs inferred (§3); declares timeline identity | `EVENT-TYPE-VOCABULARY.md` (field ref: `event-schema.md`) | + +**Already closed elsewhere (cross-link, don't redo):** trust tiers 🟢/🔴; +capabilities (least-authority set, `types.ts` L119); four-layer redaction +(`redaction-design.md`); timeline surfaces §8 (map / I/O reader / HUD / search); +Settings by necessity tier × front door (F3 ticket, §9). + +## Cross-references + +- Worked instances: `PLUGIN-ROLES.md`, `CAPTURE-SOURCE-TAXONOMY.md`, + `DETECTOR-ROLES.md`, `SPEC-SCOPE-AWARE-LIFECYCLE.md` +- The laws a decomposition must carry: `DESIGN-PRINCIPLES.md` (§2 capture, §3 + facts-vs-suggestions, §7 one-implementation faces, §8 timeline surfaces) +- Deep-module vocabulary (seams, interfaces): the `codebase-design` skill diff --git a/docs/DELIVERY-TARGETS.md b/docs/DELIVERY-TARGETS.md new file mode 100644 index 00000000..a0b840e9 --- /dev/null +++ b/docs/DELIVERY-TARGETS.md @@ -0,0 +1,96 @@ +# Delivery Targets — how evidence leaves RedLog + +Written 2026-08-13. Applies `DECOMPOSITION-METHOD.md` to the delivery/export +subsystem (`bundle-export`, `cloud-share`, `deconfliction`, `signing`). It also +absorbs the boundary that `CONTROL-PLANE-FACES.md` split out: delivery ops +(`export/bundle`, `sanitize`, `deconfliction`, cloud-share) are **not** control +ops — they live here. Its job: **every way evidence leaves RedLog is one of two +roles, declares a sanitize profile by audience, and carries a shape-appropriate +integrity contract — so "add a delivery target" is filling a template, and no path +can leak un-sanitized or unverifiable evidence.** + +## The two roles (mechanism axis: timing / cardinality) + +``` +Produce a complete, self-verifiable artifact of the store at a point in time? → SNAPSHOT +Forward selected events in real time as they happen? → STREAM +``` + +Everything else (destination, audience, sanitize level) is a *classification*, not +a role — see the axes below. + +## Master table + +| Target | Role | Audience | Sanitize profile | Timing | Transport | Integrity | Source | +|---|---|---|---|---|---|---|---| +| **Evidence bundle** | Snapshot | third-party / court / client | layer-4 sanitized (raw→sanitized swap) or full (opt-in) | batch | local file (out dir) | Ed25519 sig + OTS anchor + manifest + sanitized-reconciliation + **scope-adherence proof** (`ALERT-ROLES.md` G-D1) | `bundle-export.ts` | +| **Cloud share** | Snapshot | operator / shareable link | redaction-gated (`RedactionPreview`) | batch | remote PUT (Worker/S3) + `expiresAt` | manifest + sanitize counts (verify it also signs/anchors — gap #3) | `cloud-share.ts` + `-uploader.ts` | +| **Deconfliction feed** | Stream | blue team | subtype-filtered (`scope_violation` default) + `includeData` flag | real-time per-event | signed webhook (HTTP POST) | HMAC per body | `deconfliction.ts` | + +## The two classifying axes + +- **Audience** → sets the **sanitize profile** (the tier axis): third-party (must + not leak out-of-scope/PII) / blue-team (filtered subset only) / operator-link + (redaction-gated). Audience is *why* a profile is chosen. +- **Transport** → local file / remote upload+expiry / webhook. Independent of role. + +## The uniform contract every target fills + +1. **Declare audience → sanitize profile.** Not ad-hoc per target — one of the + named profiles (see Gaps #1). A Snapshot for a third party runs the scope-aware + sanitize planner (`SPEC-SCOPE-AWARE-LIFECYCLE.md` Part B); a Stream declares its + event/subtype filter. +2. **Carry shape-appropriate integrity.** Snapshot → signed + anchored + a manifest + that reconciles every sanitized swap (a stripped bundle is *detectable*, not + silently smaller). Stream → per-body signature so each forwarded event is + authenticable. +3. **Append an audit event for the delivery action** (export/upload/flush), so the + act of delivering is itself on the chain. +4. **Test seam:** the artifact/payload builder is pure — `build(store, profile) → + artifact` — testable without the transport (cloud-share already splits builder + from uploader "to keep the redaction gate testable", `cloud-share.ts` L16). + +## Per-role reference + +### Snapshot — a complete, self-verifiable artifact + +- **Produces:** the whole store (or a scoped slice) as a signed, anchored, + manifest-described artifact a third party can verify **without RedLog** + (`bundle-export` ships `signerPubKey` for `tools/redlog-verify.py`, L122). +- **Sanitize:** runs the profile before write — layer-4 raw→sanitized swap + (`bundle-export` L86–101), each swap reconciled by a paired `system.sanitized` + event so tampering (stripping bytes) is detectable. +- **Transport variants:** local file (bundle) vs remote upload with expiry + (cloud-share) — same role, different transport. +- **Instances:** evidence bundle, cloud share. **Plugin face:** the reserved 🔴 + `exporters` role (`PLUGIN-ROLES.md`) — a plugin-contributed Snapshot format; its + first instance is the scope-sanitized client bundle. + +### Stream — a real-time per-event feed + +- **Produces:** selected events forwarded as they land, each independently signed. +- **Sanitize:** by *filter*, not redaction — only whitelisted subtypes + (`scope_violation` default, `deconfliction.ts` L21) and an `includeData` toggle + decide what leaves. Buffered with a shutdown flush (L158). +- **Transport:** signed webhook. Config (URL/secret/filter) is frozen per batch to + avoid mid-batch drift (L137). +- **Instances:** deconfliction feed. **Plugin face:** none yet — a plugin SIEM/ + blue-team forwarder would be a gated plugin Stream (Gap #4). + +## Gaps this framework surfaces + +| # | Gap | Role | Fix | +|---|---|---|---| +| 1 | Sanitize is ad-hoc per target (bundle: layer-4 `sanitized_events`; cloud-share: own `RedactionPreview`; deconfliction: subtype filter) | both | unify into **named sanitize profiles** (`full` / `scope-sanitized` / `filtered`) shared across all targets — the same planner as lifecycle Part B | +| 2 | Delivery ops sit on the REST control face | — | move `export/bundle`, `sanitize`, `deconfliction`, cloud-share off the §7 control catalog onto a Delivery face (`CONTROL-PLANE-FACES.md` gap #3) | +| 3 | Cloud-share integrity may be weaker than bundle | Snapshot | verify cloud-share carries the same Ed25519 + OTS as `bundle-export`; a "verifiable snapshot" contract should be uniform, not per-transport | +| 4 | No plugin path for either role | both | `exporters` (🔴, reserved) = plugin Snapshot; add a gated plugin Stream for SIEM/blue-team forwarders | +| 5 | Snapshot scope-sanitize planner not wired | Snapshot | implement `SPEC-SCOPE-AWARE-LIFECYCLE.md` Part B as the third-party Snapshot's default profile | + +## Cross-references + +- The method: `DECOMPOSITION-METHOD.md` +- Sanitize/retention/rotation this shares profiles with: `SPEC-SCOPE-AWARE-LIFECYCLE.md` +- The plugin Snapshot role: `PLUGIN-ROLES.md` (Exporter) +- The control-plane boundary this closes: `CONTROL-PLANE-FACES.md` gap #3 +- Bundle threat model + verify: `audit-trail.md`, `CLOUD_SHARE_BUNDLE.md` diff --git a/docs/DESIGN-PRINCIPLES.md b/docs/DESIGN-PRINCIPLES.md new file mode 100644 index 00000000..5f6d5064 --- /dev/null +++ b/docs/DESIGN-PRINCIPLES.md @@ -0,0 +1,177 @@ +# Design Principles + +Written 2026-08-11. These are the durable laws behind RedLog's design — the +things that should still be true five versions from now, and the yardstick every +"should we build X?" question answers to. They were settled in a decision review +(the grilling of 2026-08-10/11) and are the source of truth that +`PRODUCT-POSITIONING.md`, `ROADMAP.md` and the UX docs elaborate. When a specific +doc disagrees with a principle here, the principle wins and the doc is the bug. + +Each principle carries a one-line *why* and, where it matters, the *test* it +gives you for future decisions. + +--- + +## 1. RedLog is an evidence tool; passive capture is the feeder + +The irreducible core is the tamper-evident record a third party can verify: +hash chain + OpenTimestamps + operator attribution + signed bundle. Passive +capture is **how the chain gets fed**, not the point. It is necessary — a record +you must remember to make has holes exactly where the interesting things +happened, and a holey record is not defensible — but its necessity is *derived +from* the evidentiary core, not independent of it. + +**Test:** a feature earns its place iff it (i) strengthens the chain, (ii) feeds +capture that would otherwise be lost, or (iii) serves the live-OPSEC front door +(§4). Anything else is a frozen secondary identity (§3) or scope creep. + +## 2. Capture broad, sanitize/prune later — and invest in detection + +For an evidence tool, capturing *more content* is not safer: exhaustive capture +vacuums up client PII, credentials and out-of-scope data, which is evidentiary +*liability*. But capturing *narrowly* loses reconstruction you can't get back, +and you cannot always know at capture time what will matter. RedLog resolves this +the way `redaction-design.md` already prescribes: **capture full, sanitize on +export.** Bytes never enter the chain (only their sha256); bodies live in a +prunable sidecar; sanitization is a downstream, chained, attributable action. + +This makes the **detection/sanitize/prune machinery necessary, not optional** — +loot detector, entropy redaction, scope monitor, io sidecar, retention pruning, +export sanitize. Broad capture without strong detection is just an +undifferentiated pile of sensitive data in your evidence store. + +**Corollary — default-on vs opt-in by source:** capture the operator's *own +deliberate actions* (commands, agent tool calls, navigation, targets) on by +default; ambient *vacuums* (clipboard, periodic screenshots) opt-in — not because +they shouldn't be captured, but because they must run continuously and carry an +exposure window, so the operator should consciously accept that bet. (RedLog +already does this: clipboard off by default.) + +## 3. Two-tier attributes: record facts, never write interpretation as fact + +RedLog records **facts** — including the fact "detector D fired at T with +confidence C." It **never** records an *interpretation* as authoritative ground +truth. Authoritative assertions come from exactly two places: the **operator** +(a marker) or **primary capture** (a command was actually run). Everything +interpreted — phase, target when ambiguous, loot type, scope verdict, any MITRE +tag — is a **suggestion**: labelled `inferred`, confidence-scored, attributable +to the detector, and **promotable by the operator** to an authoritative marker. + +This is not a new philosophy — loot detection ("looks like an AWS key, +confidence high"), scope violations and redaction spans already work this way. It +is the law that keeps RedLog honest as it adds convenience. + +**Test — every "should RedLog auto-detect X?" has one answer:** *yes, as a +labelled suggestion; never as fact.* Inference lives in the derived/review layer +or as a typed detection event; it never mutates the underlying event's data, and +it is always visually distinct from an operator's assertion (e.g. a dashed +segment vs. a solid one), with one-click promotion. + +## 4. Two front doors, one reason to exist + +RedLog has two entry points: + +- **Evidence recorder** (primary) — the reason RedLog exists. Remove it and + you have an OPSEC widget, not RedLog. +- **Live-OPSEC HUD** (co-headline) — a genuinely standalone-usable second front + door. Some operators use *only* the HUD (external-IP/EXPOSED alarm, pivot + chain, capture-health) and never record an engagement. + +The HUD is a *main* feature — polished, marketed, usable on its own — but its +OPSEC scope is **frozen** (§3-of-the-freeze-list): it does not grow new OPSEC +features. "Main" means important and standalone-capable, not ever-expanding. + +**Consequences:** the IP/OPSEC/pivot monitors are *doubly* necessary (they feed +the chain *and* the HUD); there must be a **first-class HUD-only runtime mode** +(main window closed, monitors + overlay + tray keep running); and first-run +leads with the zero-setup HUD value while **stating plainly that evidence +recording is the core**, with the Capture Readiness checklist as the invited next +step. Value-first, commitment-later — but never misrepresenting which is the +point. + +## 5. Accept but freeze + +A secondary identity can be *legitimate yet capped*. This is the principled line +that lets RedLog say no to the next almost-free add-on without pretending the +existing one shouldn't exist. Currently frozen (kept, not grown): + +- **Live-OPSEC** — HUD at current scope (§4). +- **Team affordances** — multi-operator tokens, deconfliction webhook, config + profile sync. **No central server, no shared DB, no team dashboard.** +- **Local code plugins** — drop-in + trust gate; **no** exporters/monitors + contributions until there is demand. +- **Cloud share** — opt-in BYO-bucket worker; not invested in, not core surface. + +**Shelved** (a different verdict — zero demand × high ongoing cost × dangerous if +half-maintained): the **plugin marketplace** (signed registry, publisher trust, +revocation). RedLog is a **product, not a platform**. Revisit only when a real +"someone published a plugin, someone wants to install another's plugin" signal +appears. + +**Kept as necessary extensibility:** *declarative* plugins (loot/redaction/target +patterns, event types) — they extend the detection layer §2 deems necessary. + +## 6. Agents are a capture source, not a product direction + +Agent transcripts are *a capture source*, same chain, same attribution, same +pause semantics as the shell hook — not a new product line. The "AI-agent-native" +angle is real and worth marketing (natively recording agent actions into a +tamper-evident log is a genuine 2026 wedge), but it is a pillar of the **capture +layer**, not of the **control layer**. It never justifies growing an +agent-operations platform. + +## 7. Control plane: one implementation, many thin faces + +Different consumers legitimately need different transports — agents want MCP, +scripts want REST, humans want shell functions. The rule is **one canonical +implementation** (the localhost REST handlers) with every other surface a +**thin, ideally generated, adapter** that cannot drift: MCP is the blessed +agent-native face; shell functions and the Codex JSON schema are thin wrappers. +The control-op set stays minimal and **evidence-relevant only** — write evidence +(mark, quickmark), operate the chain (anchor, recording pause/resume), read for +agent decisions (scope, status, search, whoami). It never duplicates capture +(hooks own that) and never grows evidence-irrelevant operations. + +## 8. The timeline is a reconstruction surface, not a live monitor + +Live awareness belongs to the HUD (§4). The timeline's job is **post-hoc +reconstruction and review** — "reconstruct what happened, to whom, in what order; +prove scope-compliance; hand it to a third party." This lets it shed live-ops +weight (follow-mode urgency, live-tail primacy) and its two consequences follow: + +- **Organizing axis = target / phase, not source-type.** Source-type (the 18 + lanes) is a live-firehose axis answering "which streams are active" — the HUD's + job now. Review asks "what happened to *this target*" and "reconstruct *this + phase*." Source-type demotes to a filter; TargetView folds in as the target + grouping; the 18-colour problem dissolves because far fewer groups are active + at once. (Phase per §3: operator-marker segments, with inferred dashed + suggestions.) +- **Timeline = event map; transcript/exchange = I/O reader.** The timeline shows + *what happened & to whom* and surfaces I/O only as a presence glyph; the + actual input/output content is read in the paired transcript/exchange view via + drill-down. Neither tries to be the other. This also rationalises the + Timeline/TargetView/Transcript/Search sprawl into three views with distinct + jobs. + +## 9. Sequence, don't just add — surface weight mirrors necessity + +RedLog's complexity problem is that P2/P3/expert surfaces are presented to the +solo operator (P1) at equal weight from the first screen — under-sequenced, not +over-featured (with the marketplace the one genuine over-build, §5). The fix is +**progressive disclosure**: essentials prominent, frozen secondary identities +behind "advanced," shelved features removed. Settings, onboarding and the +timeline all follow this — importance decides visual weight, and "find it" +(search) is a complement to sequencing, never a substitute for it. + +--- + +## Using these + +For any proposed change, walk the tests: Does it serve §1's core (or §4's second +front door)? If it's capture, does §2's default-on/opt-in line apply? If it +interprets anything, is it a §3 suggestion, not a fact? If it's a secondary +identity, is it §5-frozen or genuinely core? Does it keep the timeline a §8 +reconstruction surface and the control plane a §7 single implementation? And does +it earn its §9 surface weight? A change that can't answer these is either scope +creep or an unstated change to the principles — make the latter explicit and +deliberate, never a side effect. diff --git a/docs/DESIGN-SETTINGS-IA.md b/docs/DESIGN-SETTINGS-IA.md new file mode 100644 index 00000000..43abd5d4 --- /dev/null +++ b/docs/DESIGN-SETTINGS-IA.md @@ -0,0 +1,151 @@ +# Design Proposal — Settings Information Architecture (F3 structural) + +Written 2026-08-12. For ratification before implementation. This is the +*structural* half of F3 (`UX-BACKLOG-TICKETS.md`): reorganise Settings so its +**weight mirrors necessity** (`DESIGN-PRINCIPLES.md` §9), not so its 34 groups +sit flat across 8 equal tabs. The filter box (F3 search) and the marketplace +shelving already shipped; this is the layout. + +The complaint (`UX-AUDIT-2026-08.md` F3): 8 tabs / 34 `FieldGroup`s / 234 i18n +keys, all equal weight — a P1's daily essentials (capture sources, scope) compete +for attention with expert-only surfaces (MCP, deconfliction, plugins) they touch +once. Under-sequenced, not over-featured. + +## Current structure (mapped to necessity tiers) + +Eight tabs today (`Settings.tsx` L182–190), 34 groups. Tier per +`DESIGN-PRINCIPLES` (Core = essential to record + evidence; OPSEC = second front +door; Advanced = frozen/secondary identities): + +| Current tab | Groups | Necessity tier | +|---|---|---| +| **General** | engagement, operator, language, uiScale | mixed — engagement/operator are **core**; language/uiScale are **app-chrome (advanced)** | +| **Capture** | hooks, agent-tailer, clipboard, screenshot(×2), file-watcher, process-monitor, excluded-paths | **Core (essential)** — the #1 thing a P1 sets up | +| **Scope** | enforcement, in-scope, excluded, scope-file | **Core (essential)** | +| **Network / IP** | ip-safety, polling, vpn-adapters | **OPSEC front door** | +| **HUD** | overlay group (all HUD/overlay knobs) | **OPSEC front door** | +| **Integrations** | cdp/proxied-browser, mcp, agents | **Advanced** (agent control plane, §6/§7) | +| **Data** | export-all, scope-export, profile-sync, export-bundle, **integrity**, update, cloud-share | mixed — **integrity + bundle export are core evidence**; profile-sync/cloud-share/update are **advanced** | +| **Plugins** | local/declarative plugins, (marketplace — shelved) | **Advanced (frozen)** | +| *(within tabs)* | operators, deconfliction | operators = **core (attribution)**; deconfliction = **advanced (team, frozen)** | + +The mismatch is visible: **evidence integrity** (core) is buried in the Data +tab next to update-check and cloud-share (advanced); **operator identity** (core +attribution) is split between General and a sub-panel; **language/UI-scale** +(pure chrome) sit in the first tab a newcomer sees. + +## Two proposals + +### Option A — Reorder + regroup the existing tabs *(lighter, lower risk)* + +Keep tab-based navigation; fix the ordering and the mis-filed groups so weight +follows necessity. No tab merging. + +1. **Tab order becomes tier order** (left→right = most→least essential): + `Capture · Scope · Evidence · OPSEC · Advanced` — plus the F3 filter spanning + all. +2. **Re-file the mis-placed groups:** + - New **Evidence** tab (was scattered): engagement, operators, integrity/chain + verify, bundle export, redaction (when it lands), profile-sync. + - **OPSEC** tab = today's HUD + Network/IP merged (one front door, §4). + - **Advanced** tab = Integrations (mcp/agents/browser) + deconfliction + + plugins + cloud-share + app-chrome (language, uiScale, update-check). +3. `General` dissolves — engagement/operator → Evidence; language/uiScale/update + → Advanced. + +Net: **5 tabs** (Capture, Scope, Evidence, OPSEC, Advanced) instead of 8, but the +*mechanism* (tabs) is unchanged — this is moving `FieldGroup`s between tab +conditionals, not rewriting them. + +### Option B — Two-level: essentials up front, everything else behind "Advanced" + +A stronger progressive-disclosure cut. Primary surface shows only what a P1 +needs; a single **Advanced** disclosure holds every frozen/secondary surface. + +- **Primary (always visible):** Capture, Scope, Evidence, OPSEC — four tabs. +- **Advanced (one collapsed section / tab, closed by default):** Integrations + (mcp/agents/browser), Team (deconfliction), Plugins, Cloud share, App + (language/scale/updates). +- The F3 filter still searches across everything (including collapsed Advanced), + so nothing becomes unfindable — just un-prominent. + +Net: the newcomer sees 4 essential tabs; the long tail is one deliberate click +away. Higher impact on the "under-sequenced" complaint, slightly more work (the +Advanced disclosure + making the filter reach into it). + +## Recommendation + +**Option A**, then adopt B's "Advanced is collapsed by default" idea for the +Advanced tab only. Rationale: A is a faithful, low-risk re-file (weight follows +necessity, `General` chrome stops greeting newcomers) that we can ship and +verify tab-by-tab; collapsing just the Advanced tab's contents by default gets +most of B's progressive-disclosure benefit without a new nav paradigm. Full B can +follow if A proves the tiers are right. + +## Migration & muscle memory + +- **F3 search already mitigates** the "where did X move?" cost — typing the + setting name finds it regardless of tab. The marketplace change already reset + Plugins-tab muscle memory, so this is a good moment to move things. +- **⌘-number / deep-links:** Settings is a single view (⌘9); the tabs aren't + individually shortcutted, so no shortcut churn. +- **No config/key changes** — this is pure presentation. Every `FieldGroup` + keeps its config path, state, and i18n key; only its parent tab changes. i18n: + add the new tab labels (Evidence, OPSEC, Advanced), keep the rest. + +## Risk & incremental strategy + +Settings.tsx is 2,681 lines; moving groups between tab conditionals is mechanical +but wide. Do it as **one tab at a time, verified in the app** (the loop is set up): + +1. Introduce the new tab list + empty new tabs; keep old tabs rendering. +2. Move groups into the new tabs one tier at a time; after each, screenshot that + tab in the running app + `renderer-smoke` + `npm run typecheck` (gate is + green — must stay 0). +3. Delete the emptied old tabs last. +4. Collapse the Advanced tab's groups by default. + +Each step is revertible; the app never ships a half-moved tab because the old one +stays until its groups are gone. + +## Acceptance criteria + +- **A1** Tabs read Capture · Scope · Evidence · OPSEC · Advanced, left→right. +- **A2** A newcomer's first tab (Capture) is an essential, not app chrome; + language/UI-scale/update-check live only under Advanced. +- **A3** Evidence integrity (chain verify) + bundle export sit together under + Evidence, not scattered in Data. +- **A4** Every setting reachable before the move is still reachable (F3 filter + finds it; Advanced is collapsed but searchable). +- **A5** No config key or behaviour changes — a saved profile round-trips + identically. `npm run typecheck` stays 0; `renderer-smoke` green; each tab + screenshotted in the app. +- **A6** i18n parity for the new tab labels. + +## RATIFIED 2026-08-12 + +- **Q1 → Option B (two-level).** Four essential tabs up front + a single + **Advanced** tab whose sections are **collapsed by default**. (More ambitious + than the A recommendation — the user chose the stronger progressive-disclosure + cut.) +- **Q2 → "Evidence"** for engagement / operators / integrity / export. +- **Q3 → merge** HUD + Network into one **OPSEC** tab (§4, one front door). +- **Q4 → PL1 stays a separate follow-up** (behaviour change, not layout). + +### Ratified target structure + +Five tabs, left→right: **Capture · Scope · Evidence · OPSEC · Advanced**. + +| Tab | Contents (FieldGroups / panels) | +|---|---| +| **Capture** | hooks (HooksPanel), agent-tailer, clipboard, screenshot (quality + group), file-watcher, process-monitor, hook-excluded-paths | +| **Scope** | enforcement, in-scope, excluded, scope-file | +| **Evidence** | engagement, operators, integrity (chain verify), export-all, export-bundle, scope-export, profile-sync | +| **OPSEC** | HUD/overlay group, ip-safety, polling, vpn-adapters | +| **Advanced** *(sections collapsed by default)* | Integrations (cdp/proxied-browser, mcp, agents) · Team (deconfliction) · Plugins (local/declarative) · Cloud share · App (language, UI scale, update-check) | + +`General` dissolves. New i18n tab labels: `settings.evidence`, `settings.opsec`, +`settings.advanced`. No config-key or behaviour changes. + +Cross-references: `DESIGN-PRINCIPLES.md` §4/§9 · `UX-AUDIT-2026-08.md` F3 · +`UX-BACKLOG-TICKETS.md` (F3, PL1) · `Settings.tsx` L182–742. diff --git a/docs/DESIGN-SYSTEM.md b/docs/DESIGN-SYSTEM.md new file mode 100644 index 00000000..b24e5ac7 --- /dev/null +++ b/docs/DESIGN-SYSTEM.md @@ -0,0 +1,383 @@ +# RedLog 設計系統 — UI/UX 語言與框架 + +以 **v0.11.7** 現況為基準,把散落在 `tailwind.config.js`、`src/renderer/src/lib/hud.ts`、 +`src/renderer/src/styles/index.css` 三處的設計決策**收斂成單一權威來源**,並標出目前的不一致。 + +這份文件是「規範(normative)」:新元件依此建;既有元件逐步對齊。它回答四個問題 — +**用什麼顏色、什麼字、什麼大小、什麼 icon**,以及**怎麼組成一致的版面與互動**。 + +配套:[`UX-AUDIT-2026-08-13.md`](UX-AUDIT-2026-08-13.md)(現況評估)、 +[`DESIGN-TIMELINE-DISCOVERABILITY.md`](DESIGN-TIMELINE-DISCOVERABILITY.md)(Timeline 細部)。 + +外部基礎:Apple [Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines) +與 [UI Design Do's and Don'ts](https://developer.apple.com/design/tips/)。RedLog 是 macOS 優先的 +Electron 桌面 app,以 HIG **原則**為地基;但它是**高密度的專業/紅隊工具**,不是消費級 iOS app, +故對 HIG 的觸控級預設採「桌面校準」而非照搬(見 §0.1 的對齊與偏離表)。 + +--- + +## 0. 設計原則(本 app 的 UI/UX 語言) + +1. **證據優先,冷靜呈現**:這是長時間注視的稽核工具。accent 一律用**去飽和**版本,避免在暗底上「震動」 + (已在 `soften` map 建立,見 §1)。紅色是狀態語彙的一部分(danger/exposed/recording),不是裝飾。 +2. **一個手勢只做一件事**:不做「結果取決於看不見狀態」的互動;必須依狀態改變時,把狀態顯示出來 + (見 [`DESIGN-TIMELINE-INTERACTION.md`](DESIGN-TIMELINE-INTERACTION.md) 原則 2)。 +3. **空狀態一律自我說明**:沒東西時要嘛引導下一步(CTA),要嘛說明「為什麼是空的」。 +4. **漸進揭露**:主控制常駐;長尾收在 `?`/`Collapsible`/「進階」之後。 +5. **色彩不是唯一訊號**(a11y):狀態同時用顏色+形狀/文字(如 REC 用紅點+"REC" 字)。 + +上述五條與 Apple HIG 的三大支柱同源:**Clarity(清晰)**(原則 5 對比+形狀)、 +**Deference(讓位於內容)**(原則 1 冷靜、暗底、內容優先)、**Depth(層次)**(HUD 的框/光暈/模糊建立層級)。 + +--- + +## 0.1 與 Apple HIG 的對齊與偏離 + +HIG 是**桌面級**基礎;RedLog 是高密度紅隊工具,部分 HIG 觸控預設**刻意偏離**。下表對每條給 +「對齊 / 偏離」判定與處置。判定為「偏離—待修」者,已回填到 [`UX-AUDIT-2026-08-13.md`](UX-AUDIT-2026-08-13.md) 的清單。 + +| HIG 準則 | 具體值 | RedLog 現況 | 判定 | +|---|---|---|---| +| **點擊目標大小** | 觸控 44×44pt(指標裝置可較小,但仍需舒適) | `w-5 h-5`(20px)鈕、詳情面板 4px 拖曳把手、截圖刪除 `×` 等原本遠低於此 | 🔧 **偏離—已實作 hit-slop**:桌面滑鼠可小於 44pt。規範:**互動控制的可點區 ≥ 28×28px**、拖曳把手命中區 ≥ 8px,用 `.hit-target`/`.hit-target-v` 撐命中區、**視覺仍小**(見 §0.1a) | +| **最小文字尺寸** | ≥ 11pt(≈14.7px @96dpi)供免縮放閱讀 | `text-sm`≈14.9px(≈11pt) 合格;`text-xs`≈12.8px 為本專案桌面校準下限;`text-2xs`/`text-3xs` 更小 | 🔧 **偏離—已實作 C9**:17px 基準+可調 `--app-zoom`(0.9–1.5)為緩解。規範已落實:`text-2xs`/`text-3xs` **只給非必要裝飾性 meta**(計數、freshness、角標、chip),**必要可讀內容一律 ≥ `text-xs`**(見 §2.3 遷移狀態)。`text-3xs` 進一步收窄為**僅角標/寬度受限軸標** | +| **不需水平捲動/縮放即見主內容** | 版面貼合視窗 | 全樹僅 1 處 `md:` 斷點、`grid-cols-3/4` 寫死,800px 最小寬會擠 | ⚠ **偏離—待修**:見 §6.4 響應式斷點 | +| **控制貼近其作用的內容** | — | 多數符合(chip 在資料旁、詳情面板貼事件) | ✅ 對齊 | +| **對齊揭示資訊關係** | 文字/圖/鈕對齊 | 卡片格線、標籤欄對齊良好 | ✅ 對齊 | +| **充足對比** | 字色與底色對比足夠 | `soften` 去飽和但維持對比;中性文字 `zinc-400/500` 於 `#0a0a0a` 上足夠 | ✅ 大致對齊(`text-zinc-600/700` 用於極次要 meta 時接近下限,勿用於必要文字) | +| **高解析度資產/正確長寬比** | @2x/@3x、不變形 | 截圖 `object-cover`/`object-contain` 正確;icon 為向量字元/SVG | ✅ 對齊 | +| **一致性(平台慣例)** | 一致的控制與導覽 | 本設計系統即為此(§4 icon、§5 元件、§2 字級收斂) | ✅ 對齊中 | +| **尊重使用者偏好** | Dark Mode、動效、Dynamic Type | `prefers-reduced-motion` 已全域處理(§7);深色為預設;UI zoom 近似 Dynamic Type | ✅ 對齊 | +| **可及性(VoiceOver 等)** | 標籤、focus、語意 | `aria-label`/`focus-visible` 多數具備;缺 modal focus-trap、Settings tab a11y、`aria-live`(§8) | ⚠ **部分**:見 §8 待補 | + +**淨結論**:RedLog 在 Clarity/Deference/Depth、對比、一致性、動效偏好上與 HIG 對齊良好; +主要偏離是**點擊目標過小**與**必要文字偏小**——兩者都源自「高密度」取捨。處置不是全面放大(會犧牲密度), +而是**設下限並區分層級**:命中區 ≥28px、必要文字 ≥`text-xs`、微字級只給裝飾性 meta。 + +### 0.1a hit-slop utility(C8 實作,2026-08-13) + +在 `styles/index.css` 定義兩個 utility,以**隱形 `::before` overlay** 擴大命中區、**不改變視覺大小** +(符合「視覺仍小」): + +| Utility | 作用 | 適用 | +|---|---|---| +| `.hit-target` | `position:relative` + `::before { inset:-6px }`:16px 控制→28px、20px→32px | 一般靜態 icon-only 小鈕 | +| `.hit-target-v` | 只在上下擴 8px(`inset:-8px 0`):4px 把手→20px 抓取帶 | 細長全寬拖曳把手 | + +**已套用(14 處)**:Timeline 詳情面板拖曳把手(`hit-target-v`)、Timeline 標頭 zoom −/reset/+、 +help/filter/views/active-modes 的 `×` 與 skip-idle 等小鈕;Terminal 分頁關閉 `×`、搜尋列 ↑/↓/`×`; +截圖刪除 `×`。 + +**使用規範**: +- 元件若已是 `absolute`/`relative` 定位,**勿**用 `.hit-target`(它會把 position 設回 relative);改用只加 `::before` + 的變體或手動加大。 +- 兩個 hit-target 控制至少相隔 12px,slop 才只在「間隙」相接、不蓋到鄰居的字形。 +- **刻意未套用**:Timeline 內嵌於 filter input 的 `absolute` 清除 `×`(slop 會蓋到輸入區、竊取點擊); + Settings/ProjectPicker 的 `ListField` 移除 `×`(低頻、位於表單)——留待增量套用或改用 `absolute` 變體。 + +--- + +## 1. 色彩系統 + +### 1.1 表面(surface)階梯 — `redlog.*`(`tailwind.config.js`) + +| Token | Hex | 用途 | +|---|---|---| +| `redlog-bg` | `#0a0a0a` | 最底層背景、標題列、Sidebar | +| `redlog-surface` | `#141414` | 卡片、面板 | +| `redlog-elevated` | `#1a1a1a` | 疊在 surface 上的區塊、hover | +| `redlog-border` | `#262626` | 主要邊框 | +| `redlog-border-subtle` | `#1e1e1e` | 次要分隔線 | + +文字:`redlog-text #e5e5e5`(主)、`redlog-text-dim #a1a1aa`(次)、`redlog-muted #71717a`(標籤)。 +慣例上 zinc 階(`zinc-200/300/400/500/600`)已大量用於文字層級 — **視為與上表同義**,新程式優先用具語意的 `redlog-*`。 + +### 1.2 狀態/accent — `soften` map(`tailwind.config.js`)+ HUD(`lib/hud.ts`) + +**關鍵**:全 app 的 `red/green/amber/cyan` 已被 `soften` 重新對映,且**必須與 HUD 逐字相同**, +讓主視窗與懸浮 HUD 呈現同一組 accent。 + +| 語意 | Token(400 階) | Hex | 對應 HUD | +|---|---|---|---| +| danger / exposed / recording / 主 accent | `red-400` / `redlog-accent` | `#d75f63` | `HUD.red` | +| safe / active / healthy | `green-400` / `emerald-400` | `#5ecf9c` | `HUD.green` | +| unknown / idle / partial / warning | `amber-400` / `yellow-400` | `#d4ac5a` | `HUD.amber` | +| system / live / pivot / 資訊 | `cyan-400` / `redlog-cyan` | `#3fc7d6` | `HUD.cyan` | + +每個語意色的 300(亮,用於 hover 文字)與 500(深,用於填底)階見 `soften`。 +HUD 專屬中性色:`HUD.muted #5f7a82`(標籤)、`HUD.value #cfe8ee`(帶青的白,主要數值)。 + +### 1.3 語意色使用規則 + +- **紅**只給「危險或需要注意」:scope 違規、exposed IP、recording、刪除。不要當一般強調。 +- **青**給「系統態與導覽」:LIVE、pivot、連線、可點連結、選取態。 +- **綠**只給「確認安全/健康/成功」。**琥珀**給「未知/閒置/部分」。 +- **中性**(zinc/redlog-text-*)給絕大多數文字。一個畫面同時出現三種以上 accent = 過載,重新分層。 + +### 1.4 陰影與光暈(`boxShadow`) + +`card`(`0 1px 3px rgba(0,0,0,.4)`)、`card-hover`。光暈 `glow-red-*`/`glow-cyan-*` 用於強調態。 + +### 1.5 chrome accent drift — ✅ 已收斂(2026-08-13) + +以下 chrome 硬編值原本**未跟上 `soften` 遷移**(仍是舊的搶眼亮色),本輪已修正: + +| 位置 | 原值 | 現值 | 狀態 | +|---|---|---|---| +| `tailwind.config.js` `glow-red`/`glow-red-sm` | `rgba(239,68,68,…)` | `rgba(215,95,99,…)`(`#d75f63`) | ✅ | +| `tailwind.config.js` `glow-cyan`/`glow-cyan-sm` | `rgba(34,211,238,…)` | `rgba(63,199,214,…)`(`#3fc7d6`) | ✅ | +| `styles/index.css` `.drag-over` | `#ef4444` | `#d75f63` | ✅ | +| `OverlayApp.tsx` inline ×4(hair/iconBtn/scanline) | `rgba(34,211,238,…)` | `hexA(CYAN, …)` | ✅ | + +**規範**:renderer 的 UI chrome 內不得再出現 raw hex accent — 一律走 tailwind class 或 `HUD.*` token。 + +### 1.5b 分類色盤與狀態色(**刻意保留**,另立議題) + +以下三處也是硬編亮色,但它們**不是** chrome accent drift,而是各自成套的**分類/狀態色盤**, +單獨 soften 其中一色會破壞整組的可辨識性,故本輪不動,留待「統一分類色盤」的獨立設計: + +| 位置 | 性質 | 為何保留 | +|---|---|---| +| `TerminalView.tsx` `red/brightCyan/cursor/selection` | 終端機 ANSI 調色盤 | 終端內容的標準鮮色,特殊表面;ANSI 全套一致鮮豔 | +| `TranscriptView.tsx` `KIND_COLOR.marker #ef4444` | 分類色盤 | 與 `#22c55e/#84cc16/#8b5cf6/#f97316` 並存,需整組一起定 | +| `Timeline.tsx:3389` fail 狀態環 `#ef4444` | 語意狀態色 | 需與 Timeline 其餘 lane/status 色一起收斂 | + +**待辦(獨立 ticket)**:定義一套「lane/kind 分類色盤」與「語意狀態色」token(含 §1.2 的 danger red 對映), +再讓上述三處引用,而非各自硬編。 + +--- + +## 2. 字型系統 + +### 2.1 字族(family) + +| 角色 | Stack | 位置 | +|---|---|---| +| UI(介面文字) | `-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft JhengHei UI', system-ui, sans-serif` | `styles/index.css` body | +| 等寬(數值/ID/指令/時間戳) | `ui-monospace, 'SF Mono', Menlo, 'Cascadia Code', Consolas, 'Microsoft JhengHei UI', monospace` | 多處 `font-mono` | +| 終端機 | `'MesloLGS NF', 'FiraCode Nerd Font', 'JetBrainsMono Nerd Font', 'Hack Nerd Font', 'SF Mono', Menlo, …` | `TerminalView.tsx:314` | + +**規範**:所有「機器產物」(IP、hash、event id、port、timestamp、指令)用 `font-mono` + `tabular-nums`; +散文用 UI 字族。`Microsoft JhengHei UI` 已納入兩條 stack,確保 Windows 上中文不掉字。 + +### 2.2 字級(size)— 目前的問題 + +現況掃描(renderer 內)顯示**兩套並行的字級尺標**,且分布碎裂: + +``` +Tailwind rem 制:text-xs ×350 text-sm ×34 text-base ×2 text-lg ×6 text-xl ×3 +硬編 px 制: text-[11px] ×138 text-[10px] ×33 text-[13px] ×7 text-[9px] ×4 text-[8px] ×2 text-[12px] ×1 +``` + +問題有二: +1. **兩套尺標語意重疊**:`text-xs`(=0.75rem,在 17px 基準下 ≈12.75px)與 `text-[11px]`/`text-[13px]` + 互相接近卻不成階,維護時無從判斷該用哪個。 +2. **縮放行為不一致**:rem 制會跟著 `html { font-size: 17px }` 放大;硬編 px **只跟 `--app-zoom` 放大** + (`styles/index.css` 註解已載明此權宜)。兩者在使用者調字級時步調不同。 + +### 2.3 規範字級尺標(type scale) + +收斂成 7 級,一律用 Tailwind 具名 class(rem 制,隨基準縮放)。`text-2xs`/`text-3xs` +已於 `tailwind.config.js` `fontSize` 定義(string 形式=只設 font-size,行為與被取代的 arbitrary 值一致): + +| 級別 | class | ≈px @17基準 | 用途 | +|---|---|---|---| +| nano | `text-3xs`(`0.5rem`) | ~8.5 | 最密的徽章/角標(取代 `text-[8px]`/`text-[9px]`) | +| micro | `text-2xs`(`0.625rem`) | ~10.6 | chip 計數、freshness 標籤、極次要 meta(取代 `text-[10px]`) | +| caption | `text-xs` | ~12.8 | **預設次要文字**(絕大多數 label、hint) | +| body | `text-sm` | ~14.9 | 主要可讀內文、表單值 | +| title | `text-base` | ~17 | 卡片/區塊標題 | +| heading | `text-lg` | ~19 | 頁面標題 | +| display | `text-xl` | ~21 | 大數字(Dashboard 統計) | + +**遷移狀態**: +- ✅ 已完成(2026-08-13):`text-[10px]` → `text-2xs`(33 處)、`text-[9px]`/`text-[8px]` → `text-3xs`(6 處)。 + 這一批視覺位移極小(10→10.6px、8/9→8.5px),但字級**從此隨 17px 基準縮放**(原本只隨 `--app-zoom`)。 +- ✅ C9 必要文字下限(2026-08-13):把**必要可讀但過小**的文字提到 `text-xs` — Timeline 互動圖例(T1 legend, + pan/zoom/filter/shortcuts)與滾輪模式提示(wheel-mode hint),兩者都是「核心手勢絕不是祕密」的導覽文字, + 8pt 太小。其餘微字級(計數、freshness、chip、footer、角標)經審視確認為裝飾性 meta,維持不變。 + `text-3xs` 僅保留給 instance 角標/count 徽章與寬度受限的軸標(3418/3429/3504/3206/3228);palette 分類標由 `text-3xs`→`text-2xs`。 +- ⏳ 待做(**非低風險**,另立):`text-[11px]`(138 處)→ `text-xs`、`text-[13px]`(7 處)→ `text-sm`。 + 這兩批會放大 ~15%(11→12.8、13→14.9),需搭配逐頁視覺 pass,故不併入 token 收斂批次。 +- 例外:終端機字級(`TerminalView` 的 `⌘±`)與 HUD `scale` 是使用者可調的獨立系統,維持 px 動態計算。 + +### 2.4 字重與字距 + +- 區塊標題慣例:`text-[11px] font-semibold uppercase tracking-[0.15em] text-zinc-500`(見 Dashboard、Settings)。 + → 收斂為 utility `.section-heading`(見 §5)。 +- 數值:`font-mono font-semibold tabular-nums`。 +- 一般文字:`font-medium` 或預設。避免 `font-bold` 大面積使用。 + +--- + +## 3. 間距、尺寸與圓角 + +- **間距單位**:沿用 Tailwind 4px 網格。區塊間 `space-y-3`/`gap-3`;卡片內距 `p-4`;緊湊列 `gap-2`。 +- **圓角**:卡片 `rounded-lg`;chip/按鈕 `rounded`/`rounded-md`;徽章 `rounded-full`。 + HUD 專屬斜角 `CHAMFER`/`CHAMFER_SM`(`lib/hud.ts`)僅限 overlay,勿用於主視窗。 +- **控制高度**:導覽項 `h-8`;工具列鈕 `py-1`;狀態列 `h-7`;標題列 `h-10`。收斂見 §5 按鈕。 +- **邊框**:一律 `border-redlog-border`;hover 態可 `border-zinc-600`。 + +--- + +## 4. Icon 系統 + +### 4.1 現況:ASCII glyph,無統一集 + +目前 icon 是散落的 Unicode 幾何字元,來源不一、隱喻不一致、辨識度低: + +``` +Sidebar: ◉ dashboard ▸ terminal ═ timeline ☰ transcript ◻ screens + ⊕ targets ⊘ scope ◆ loot ⚑ marks ⚙ settings +其他: ⌕ search ⚓ anchor ⊞ 軸切換 ▤ phase ribbon ⚡ 快速標記 ↗ 跳轉 ⠿ 拖曳把手 +``` + +問題:`═`(timeline)、`☰`(transcript)、`◻`(screens)之間沒有共同視覺語言; +`◉/◆/◻` 都是「實心/空心幾何」但語意無關;字型 fallback 不同平台粗細不一。 + +### 4.2 規範:兩條路線(擇一,建議 A) + +**路線 A(建議)— 導入單一 stroke icon set** +採用一套開源、線性、可設 `currentColor` 的 SVG icon(如 Lucide 風格),統一 `1.5px` stroke、24×24 grid。 +每個 sidebar 項給明確隱喻:dashboard=gauge、terminal=terminal、timeline=activity/waveform、 +transcript=list、screens=image、targets=crosshair、scope=shield、loot=key、marks=flag、settings=settings。 +以 inline SVG 或本地 sprite 引入(Electron 無 CSP 外連問題,但沿用專案「自帶資產」慣例)。 + +**路線 B(低成本過渡)— 收斂現有 glyph 為一致集** — ✅ 已實作(2026-08-13) +- 建立 `src/renderer/src/lib/icons.ts`,匯出**單一來源** `ICON` map(nav 實體 + 跨切面 affordance), + 以 Sidebar 為 canonical 定義。8 個檔(Sidebar/App/StatusBar/TerminalView/LootPanel/ScopeStatus/ + TranscriptView/FindingsView)改引 `ICON.*`,非 Timeline 檔已無裸語意 glyph。 +- **順帶修掉兩處實體漂移**:TranscriptView 空狀態 `▤`→`ICON.transcript`(`☰`,與 nav 一致、且解除與 + phaseRibbon 的撞號);FindingsView 空狀態 `◈`→`ICON.marks`(`⚑`,與 nav 一致)。 + loot `◆` 原散在 3 檔(Sidebar/StatusBar/LootPanel),現同引一個 token。 +- 規範:每個 glyph render 時 `aria-hidden` 且旁邊恆有文字 label/`aria-label`;大小顏色走型別/色彩 token, + `icons.ts` 只帶 glyph。 +- ⏳ 待收尾:Timeline.tsx 內 3 個單站 glyph(空狀態 `═`、軸 `⊞`、phaseRibbon `▤`)留待該檔其他 + in-flight 變更落地後再引 `ICON.*`(避免與並行工作衝突);generic affordance(`✕`/`✎`/`+` 等)可日後增量遷入。 +- 未來切路線 A 時,只需替換 `icons.ts` 這一個 map 的值,呼叫端不動。 + +### 4.3 通則 + +- icon 恆搭配文字 label 或 `aria-label`(純 icon 鈕如標題列 search `⌕` 已有 `aria-label`,維持)。 +- icon 尺寸跟隨字級(`w-4 h-4` 對 `text-sm`)。 +- 狀態用色遵 §1.2;不可只靠 icon 形狀傳達狀態。 + +--- + +## 5. 元件模式(component patterns) + +把重複出現的 Tailwind 組合收斂為具名 pattern(可做成 `@layer components` utility 或 React 元件)。 + +### 5.1 卡片 Card +``` +rounded-lg bg-redlog-surface border border-redlog-border p-4 shadow-card +(可 hover:shadow-card-hover) +``` +頂部狀態條:``(見 `StatCard`、`CaptureHealthCard`)。 + +### 5.2 區塊標題 `.section-heading` +``` +text-xs font-semibold uppercase tracking-[0.15em] text-zinc-500 +``` +(統一目前 `text-[11px]`/`text-[10px]` 混用;見 Dashboard vs Settings 的細微差異。) + +### 5.3 Chip(篩選/狀態標籤) +- 未選:`bg-zinc-800 text-zinc-500 hover:text-zinc-300` +- 選中:`bg-red-500/20 text-red-300`(或對應語意色) +- 停用:`opacity-25 cursor-not-allowed` +- 恆帶 `focus-visible:ring-1 focus-visible:ring-red-500/40`。 +**規範**:篩選 chip 的「出現閾值」統一(見 audit C4)— 不論資料量恆顯示,或恆用同一門檻,別各頁不同。 + +### 5.4 按鈕 Button(三級) +| 級別 | 樣式 | 用途 | +|---|---|---| +| primary | `bg-red-500/10 text-red-400 border border-red-500/15 hover:bg-red-500/20` | 主行動(Mark、儲存) | +| secondary | `bg-zinc-800 text-zinc-300 hover:bg-zinc-700` | 一般行動(Export) | +| ghost | `text-zinc-500 hover:text-zinc-300 hover:bg-white/[0.03]` | 低權重/導覽 | +| danger | 同 secondary,但 hover 轉 `text-red-400`;**破壞性一律走 `confirm(..., danger=true)`** | 刪除/revoke | + +### 5.5 徽章 Badge、Toast、ConfirmDialog、EmptyState、LoadingSpinner +- 已有共用元件(`Toast.tsx`、`ConfirmDialog.tsx`、`Feedback.tsx` 的 `EmptyState`/`LoadingSpinner`)。 + **規範**:所有空狀態走 `EmptyState` + `emptyStateFor()`;所有破壞性確認走 `confirmDialog`。 +- Modal 一律加 **focus trap**(目前 `EventMarker` 只有 autofocus,缺 trap — audit C6)。 + +--- + +## 6. 版面與縮放框架(回應「畫面佔比可調」) + +### 6.1 目前結構 +`App.tsx`:標題列(`h-10`) → 主體(`Sidebar` 固定 140px + `view-root flex-1`) → `StatusBar`(`h-7`)。 +全 app 唯一可拖曳縮放是 Timeline 詳情面板(`Timeline.tsx:3571`,存 localStorage)。 + +### 6.2 規範:共用 `` — ✅ 已實作(2026-08-13) + +可重用的分割元件(`components/SplitPane.tsx`),把 Timeline 詳情面板已驗證的模式一般化: + +```tsx + + {/* 可調整大小的第一欄;px 大小存 localStorage redlog-split-{id} */} + {/* flex-1 填滿其餘 */} + +``` +- 拖曳把手:1px 視覺 + `.hit-target` 命中區(§0.1a),`cursor-col/row-resize`、`hover:bg-redlog-accent/50`。 +- 雙擊把手 → 重設並清 localStorage;`←/→`(或 `↑/↓`)鍵每步 16px(a11y,`role="separator"`)。 +- `ResizeObserver` 在容器縮小時重新 clamp,避免面板寬過頭。 +- 純函式縫:`splitPaneClamp(px, min, max, containerPx, otherMin)`(`lib/splitPane.ts`, + `test/split-pane.test.ts` 7 例)。 +- **已套用**(2026-08-13):FindingsView 左右欄(原 `w-80` 硬編 → 可拖曳);**Loot** 清單|詳情(詳情面板顯示 + 完整值 + 複製鈕,順帶修掉 audit C1 的「preview 截斷、不能複製」);**Transcript** feed|詳情(詳情面板顯示 + 選定交換的完整 input/output + 各自複製鈕,補足 feed 無複製、output 截斷的缺口);**Terminal 分割窗** + (`⌘D`/◫ 按鈕把一個 tab 分成兩個並排的 shell,每個獨立 pty,中間可拖曳調整佔比;`defaultSize` 支援分數 0.5=五五分)。 +- **`defaultSize` 支援分數**:值在 (0,1] 視為容器比例(如 0.5=一半),首次量到容器時解析;持久化的一律是 px。 + +### 6.3 規範:Sidebar 可收合 — ✅ 已實作(2026-08-13) +- 底部 toggle(`«`/`»`)或 **`⌘/Ctrl+B`**(App 派發 `redlog:toggle-sidebar`,Sidebar 監聽)在 + icon-only(52px)↔ 展開(140px)間切換,狀態存 localStorage `redlog-sidebar-collapsed`,`transition-[width]`。 +- 收合時只剩 icon(tooltip 帶 label);count badge 改為 icon 角落小圓點;drag 重排把手僅展開時顯示。 + +### 6.4 規範:響應式斷點 +- 目前全樹僅 1 處 `md:`。關鍵格線(Dashboard `grid-cols-3/4`、Screenshots `grid-cols-3`) + 補 `sm:`/`md:`:如 `grid-cols-1 sm:grid-cols-2 md:grid-cols-3`。 +- 最小視窗 800×500 必須無水平溢出、無擠壓。 + +### 6.5 全域縮放(維持) +`--app-zoom`(Chromium `zoom`,0.9–1.5,`main.tsx:11`)+ `html { font-size: 17px }` 兩段式,維持現狀; +§2.3 字級收斂後,px 硬編減少,兩段式落差自然縮小。 + +--- + +## 7. 動效(motion) + +- 已定義:`pulse-slow`、`toast-in`、`blink-rec`、`spin-slow`(`tailwind.config.js`)+ HUD `alarm`。 +- **規範**:轉場一律 `transition-colors`/`transition-opacity`,時長 `duration-150`;避免大位移動畫。 +- **a11y**:`prefers-reduced-motion: reduce` 已在 `styles/index.css` 全域處理(凍結動效但保留顏色資訊)。 + 新動效**必須**能被此 media query 凍結 — 不可用動畫傳達唯一資訊。 + +--- + +## 8. 可及性(a11y)基線 + +- 互動元素恆有 `focus-visible:ring`(多數已具備)。 +- 純 icon 控制恆有 `aria-label`。 +- 狀態不只靠顏色(§0 原則 5)。 +- Modal:`role="dialog"` `aria-modal` + **focus trap** + Escape + backdrop 關閉(EventMarker 待補 trap)。 +- Settings tab bar 待補 `role="tablist"`/`role="tab"`/`aria-selected`/方向鍵導覽。 +- 動態結果清單(搜尋)待補 `aria-live`。 + +--- + +## 9. 採用路線(roadmap) + +**Phase 1(token 收斂,低風險)** — 部分完成(2026-08-13): +- ✅ 修 §1.5 chrome 亮色殘留(glow×4、`.drag-over`、OverlayApp inline×4)。 +- ✅ `tailwind.config.js` 定義 `text-2xs`/`text-3xs`,並遷移 sub-`text-xs` 硬編(39 處)。 +- ⏳ §1.5b 分類/狀態色盤(另立 ticket);§2.3 的 `text-[11px]`/`text-[13px]` 遷移(需視覺 pass); + §5.2 `.section-heading` utility 化。 + +**Phase 2(框架化)**:抽 ``(§6.2)+ Sidebar 收合(§6.3)+響應式斷點(§6.4)。 +每項先落純函式縫再接 UI。 + +**Phase 3(icon 與元件對齊)**:依 §4 擇路線導入/收斂 icon;元件逐步遷到 §5 pattern;補 §8 a11y。 + +每個 Phase 都遵本 repo 的 red→green→integrate→cover TDD 流程 +(見 [`DEV-REQUIREMENTS-capture-onboarding.md`](DEV-REQUIREMENTS-capture-onboarding.md))。 diff --git a/docs/DESIGN-TIMELINE-DISCOVERABILITY.md b/docs/DESIGN-TIMELINE-DISCOVERABILITY.md new file mode 100644 index 00000000..58caccb8 --- /dev/null +++ b/docs/DESIGN-TIMELINE-DISCOVERABILITY.md @@ -0,0 +1,199 @@ +# Timeline 發現性細部重設計 — 滾輪與軸切換 + +以 **v0.11.7** 為基準。這份文件**延伸** [`DESIGN-TIMELINE-INTERACTION.md`](DESIGN-TIMELINE-INTERACTION.md): +它把該文的 **T2(wheel-mode)** 從「事後閃現提示」深化為「常駐、恆真的模式指示」, +並**新增**該文完全未涵蓋的一整軌 —— **軸切換(source ↔ target)發現性 A1–A3**。 + +問題來源:[`UX-AUDIT-2026-08-13.md` §2.4](UX-AUDIT-2026-08-13.md)。 +設計原則沿用既有文件:**核心手勢絕不是祕密**、**一個手勢只做一件事,狀態必須可見**。 + +前置縫(已存在,不重造):`lib/timelineWheel.ts` 的 `wheelMode(ctx)`、 +`lib/timelineModes.ts` 的 `activeModes(state)`、`lib/timelineAxis.ts`、`lib/targetGrouping.ts`。 + +--- + +# 第一部:滾輪發現性(延伸 T2) + +## 1.1 為什麼 T2 的閃現提示還不夠 + +T2 已把決策抽成純函式 `wheelMode(ctx)`(`zoom` / `pan-x` / `scroll-y`),並在 `overflow` 為真時 +於軌道右緣**閃現**一則提示。這解決了「決策不可測」,但**發現性仍是被動的**: + +1. 提示只在 `overflow` 且**已經滾動之後**出現、幾秒後淡出 —— 操作者是**滾錯方向後**才被告知。 +2. `overflow` 本身看不見:它隨視窗高度、開了幾條泳道、有沒有開詳情面板而動態改變 + (`Timeline.tsx` 依 `laneStack.scrollHeight > clientHeight + 1` 判定)。使用者無法預判「這一滾會是平移還是捲動」。 +3. T1 常駐圖例寫死「`⌘-scroll to zoom`」,**從不提 `scroll-y` 或 `⇧` 逃生口**,與實際行為不一致。 + +> 目標:把「這一滾會做什麼、按住修飾鍵又會做什麼」變成**滾之前就看得到**的常駐資訊, +> 而不是滾之後才補救。 + +## 1.2 W1 — 常駐 wheel-mode 指示(affordance) + +在軌道右上角放一個**低權重、恆常存在**的小指示,反映**當前語境下**無修飾鍵滾動的結果, +並在 hover/按下修飾鍵時預覽其他兩種結果。它是 T1 圖例中「zoom」那一格的動態化。 + +``` +┌─ track ────────────────────────────────────────────────┐ +│ ┌────────────┐ │ +│ shell ● ● ● ● ●●● │ ⇔ 平移時間 │ │ ← 未溢出:滾動 = pan-x +│ agent ● ● ●●● ● └────────────┘ │ +└────────────────────────────────────────────────────────┘ + + 溢出時(泳道被裁切在下方): + ┌────────────┐ + │ ⇅ 捲動泳道 │ ← 未按修飾鍵:滾動 = scroll-y + │ ⇧ 改平移 │ 並提示逃生口 + └────────────┘ +``` + +規則: +- **恆常顯示**(不淡出),但視覺極輕(`text-2xs text-zinc-600`,見 `DESIGN-SYSTEM.md §2.3`)。 +- 內容由**當前 live 語境**決定:主行程每次 `overflow` 變化即更新,所以它**先於**滾動就說出結果。 +- 溢出時多顯示一行 `⇧ 改平移`(逃生口),對齊 `wheelMode` 表中 `shift → pan-x` 那列。 +- `⌘/Ctrl` 一律 = zoom,這點在 T1 常駐圖例已有,指示不重複,但 hover 時可預覽(見 W2)。 + +## 1.3 W2 — 修飾鍵預覽(按下即顯,放開即還原) + +當操作者**按住**修飾鍵(尚未滾動)時,指示即時切換到該修飾鍵的結果,形成「按住看預覽 → 滾動執行」的閉環: + +| 當前按住 | 指示顯示 | 對應 `wheelMode` | +|---|---|---| +| 無 | `⇔ 平移時間`(未溢出)/`⇅ 捲動泳道`(溢出) | `pan-x` / `scroll-y` | +| `⇧` | `⇔ 平移時間`(把 scroll-y 換回 pan-x) | `pan-x` | +| `⌘`/`Ctrl` | `⤢ 縮放(游標錨點)` | `zoom` | + +這讓三種模式**在同一個位置、同一個當下**被看見,取代「靠記憶+事後提示」。 + +## 1.4 W3 — 純函式縫 `wheelModeLabel(ctx)` + +在既有 `wheelMode(ctx)` 旁新增一個純函式,把模式對映到顯示標籤與逃生口提示,供 W1/W2 與 T1 圖例共用: + +```ts +// lib/timelineWheel.ts(延伸既有檔) +export interface WheelModeView { + mode: WheelMode // 'zoom' | 'pan-x' | 'scroll-y' + labelKey: string // i18n:⇔ 平移時間 / ⇅ 捲動泳道 / ⤢ 縮放 + escapeHintKey?: string // 僅 scroll-y:'⇧ 改平移' +} + +export function wheelModeLabel(ctx: WheelContext): WheelModeView +``` + +- 由 `wheelMode(ctx)` 推導,**單一事實來源**:T1 圖例、W1 指示、W2 預覽、T2 閃現提示(若保留) + 全部讀這一個函式,杜絕「圖例說 zoom、實際 scroll-y」的漂移。 +- 測試 `test/timeline-wheel.test.ts` 於既有 4 列矩陣上,斷言每列對應的 `labelKey`/`escapeHintKey`。 +- **a11y**:指示是文字(非純動畫),`prefers-reduced-motion` 下 W2 的切換以即時替換呈現、不做過場。 + +## 1.5 與 T2/T6 的關係 + +- W1–W3 **取代** T2(b) 的「閃現」為「常駐」;`wheelMode` 純函式(T2a)維持不動,是共同地基。 +- 長期 **T6(lane 虛擬化)消除 `overflow`**:泳道永遠塞得下、`scroll-y` 不再發生,屆時 W1 溢出分支 + 與逃生口提示一併移除,指示退化為單純的「`⇔ 平移/⌘ 縮放`」。W1–W3 是 T6 前的正解,也向後相容 T6。 + +--- + +# 第二部:軸切換發現性(新增 A1–A3) + +> 既有 `DESIGN-TIMELINE-INTERACTION.md` **未涵蓋**軸切換。這一軌是新的。 + +## 2.1 問題 + +Timeline 有兩種讀法,由標頭一顆 `⊞` 按鈕切換(`Timeline.tsx:2659`),狀態存 +`localStorage['redlog-timeline-lane-axis']`(`Timeline.tsx:1230/1233`),並可由側邊 "Targets" 深連結 +(`redlog-timeline-set-axis` 事件): + +- **source 軸**:依「事件來源類型」分泳道(shell / agent / http / scanner…)——「錄到了什麼」的視角。 +- **target 軸**:依「目標」分泳道(per-host / per-target)——「對每個目標做了什麼」的重建視角。 + +三個發現性問題: + +1. **`⊞` 是個謎樣 icon**:不點下去不知道它切換什麼、也看不出當前在哪個軸。 +2. **切到 target 軸會靜默讓整排 lane 篩選 chip 消失**:條件式 `laneAxis === 'source' &&` + (`Timeline.tsx:2924`)讓 target 軸下所有泳道 chip 不渲染。操作者按一下 `⊞`,標頭右側整區突然清空, + 零說明。 +3. **隱藏狀態變孤兒**:若切換前已隱藏某些泳道,`hiddenLanes` 仍存在於 state 與 localStorage, + 但**能把它們叫回來的個別 chip 沒了**("Show all" 全部還原鈕在 `Timeline.tsx:2917`,條件 `hiddenLanes.size > 0` + 仍在,但細粒度控制消失)。 + +## 2.2 A1 — 讓軸變成有標籤的明確選擇(分段控制取代謎樣 icon) + +把 `⊞` 換成一個**兩段式分段控制**,當前軸恆可見: + +``` +標頭: … [ 來源 | 目標 ] … ← 選中段高亮(青,見 DESIGN-SYSTEM §1.2) + ▔▔▔▔ +hover 「目標」段 tooltip:依目標主機分組,回答「對每個 target 做了什麼」 +``` + +- 兩段各帶簡短標籤(`來源` / `目標`)+ hover 說明各軸意義。 +- 選中段用 §1.2 的青色選中態;未選段 ghost。 +- 純函式縫沿用既有 `lib/timelineAxis.ts`;此步僅換 UI 呈現,行為不變。 +- 深連結("Targets" → target 軸)維持,但落地時分段控制已明確顯示在「目標」段,操作者知道自己在哪。 + +## 2.3 A2 — 保住篩選能力(target 軸不該讓人失去控制) + +target 軸下不能只是「拿掉 source 泳道 chip 就算了」。二選一(建議走 (a)): + +**(a) 提供對等的 target 篩選 chip(建議)** +target 軸下,把「來源泳道 chip」換成「**目標泳道 chip**」(per-host),沿用同一套 chip 元件與 +solo/hidden 語意(`lib/laneVisibility.ts` 已是泛化的可見性推導)。操作者在兩個軸都有一致的篩選手感, +`hiddenLanes` 不再變孤兒 —— 它按當前軸的泳道集合運作。 + +**(b) 若暫不做對等 chip**:至少在 chip 區顯示一行說明,而非靜默清空: +``` +目標軸:泳道依主機分組,來源篩選暫不適用 · [切回來源軸] +``` + +無論 (a)/(b),**"Show all" 還原鈕維持**,確保永遠有一條把隱藏泳道叫回來的路。 + +## 2.4 A3 — 切換當下的轉場說明(用既有 active-modes 機制) + +軸切換是「改變畫面意義」的操作,理應像 T3 的其他 sticky mode 一樣**自我說明**。當軸為非預設(target)時, +於 T3「Active:」列加一枚 chip(沿用 `lib/timelineModes.ts` 的 `activeModes` 縫): + +``` +│ Active: [⊞ 目標軸 ✕] [solo: 10.0.0.5 ✕] clear all │ +``` + +- chip 的 `✕` = 切回 source 軸(`clearAction: 'axis:source'`)。 +- 這把「我現在為什麼看起來不一樣」寫在畫面上,與 wheel(第一部)同一套「狀態必須可見」原則。 +- 純函式擴充:`TimelineModeState` 加 `laneAxis: 'source' | 'target'` 欄位;`activeModes` 在 + `laneAxis === 'target'` 時輸出一枚 chip;`source`(預設)時不輸出,維持「預設無列」不變式。 + +## 2.5 相位軸附註(phase ribbon `▤`) + +同一標頭還有 phase ribbon 切換(`Timeline.tsx:2669`,`▤`)與 source/target **正交**。它同樣是謎樣 icon, +建議一併給文字標籤(`相位`)並納入 A1 的處理慣例,但其開關已有 T3 表格涵蓋(`⇘`/焦點等),此處不重複規範。 + +--- + +# 建置順序與縫(皆純函式先行、可獨立出貨) + +| 步 | 項目 | 純函式縫 | 測試斷言 | +|---|---|---|---| +| 1 | W3 | `lib/timelineWheel.ts` `wheelModeLabel(ctx)` | 4 列矩陣各對應正確 label/escape hint | +| 2 | W1 | (UI,讀 W3;live `overflow`) | 未溢出顯示 pan-x、溢出顯示 scroll-y+逃生口 | +| 3 | W2 | (UI,讀 W3;監聽 keydown/keyup 修飾鍵) | 按 ⇧/⌘ 時預覽切換、放開還原 | +| 4 | A1 | `lib/timelineAxis.ts`(既有) | 分段控制反映當前軸;點擊切換 | +| 5 | A2 | `lib/laneVisibility.ts`(既有,泛化到 target 泳道) | target 軸有對等 chip;hidden 不成孤兒 | +| 6 | A3 | `lib/timelineModes.ts` 擴 `laneAxis` 欄 | target→一枚 chip;source→`[]` 不變式維持 | + +每步遵本 repo 的 red→green→integrate→cover 流程 +([`DEV-REQUIREMENTS-capture-onboarding.md`](DEV-REQUIREMENTS-capture-onboarding.md)), +不觸及擷取管線或證據鏈。 + +# 驗收準則(acceptance) + +- **W**:任一時刻,無修飾鍵滾動的結果**在滾動前**就顯示於軌道;按住 `⇧`/`⌘` 即時預覽對應結果; + 圖例、指示、預覽三處文案由 `wheelModeLabel` 單一來源產生,永不相互矛盾。 +- **A**:當前軸**恆可見且有文字標籤**;切到 target 軸後仍有對等篩選能力,且必有還原路徑; + 非預設軸在「Active:」列有可一鍵清除的 chip;切回 source 後 chip 消失、篩選 chip 復原。 +- **不變式**:全部預設(source 軸、無 sticky mode)時,「Active:」列不渲染(延續 T3)。 + +# 給維護者的開放問題 + +1. **W1 指示位置**:軌道右上角(上方 ASCII)vs. 併入 T1 底部圖例動態化?後者更集中但離游標較遠。 +2. **A2 走 (a) 對等 chip vs (b) 說明**:per-host 泳道在目標很多的交戰中可能爆量, + 是否需要對 target 泳道 chip 先做 top-N + 「更多」收合(呼應 cluster 的 50 筆上限慣例)? +3. **phase ribbon**:是否值得在本輪一併把 `▤`/`⊞`/tz 等標頭 icon 全數文字化(呼應 `DESIGN-SYSTEM §4` icon 收斂), + 或留待 T4「⋯ View」選單重構時一起處理? diff --git a/docs/DETECTOR-ROLES.md b/docs/DETECTOR-ROLES.md new file mode 100644 index 00000000..493667da --- /dev/null +++ b/docs/DETECTOR-ROLES.md @@ -0,0 +1,142 @@ +# Detector Roles — the inference layer, decomposed + +Written 2026-08-13. Applies `DECOMPOSITION-METHOD.md` to RedLog's detection / +inference layer (the ~10 bespoke `*-detector.ts` / `*-monitor.ts` files). Its job: +**every detector is one of four roles emitting one uniform §3 suggestion — so +"add a detector" is filling a template, and DESIGN-PRINCIPLES §3 is carried by the +role contract instead of each author's memory.** + +## The principle everything derives from + +By §3, RedLog records **facts** — operator markers and primary capture (a command +*was* run). **Everything a detector produces is interpretation on top of a fact, +so it is an inferred *suggestion*, never authoritative** — labelled, confidence- +scored, attributable to the detector, and operator-promotable. The detector layer +is therefore uniform on the *authority* axis (all inferred); roles are cut on the +**mechanism axis: input → output shape.** + +## The four roles (mechanism axis) + +``` +Pull a structured fact out of ONE event (a command)? → EXTRACTOR +Attach a LABEL + confidence to one event/output? → CLASSIFIER +Watch WORLD/SYSTEM state and emit on a transition? → MONITOR +Relate TWO+ events into a causal/relational edge? → CORRELATOR +``` + +## Master table (every detector file maps to one role) + +| Role | Input → Output | Built-in files | Emits | Confidence source | Plugin counterpart | +|---|---|---|---|---|---| +| **Extractor** | 1 command → a structured fact | `pivot-detector`, `target-extractor`, `technique-tagger` (`detectCleanup`/`detectFileTransfer`) | `pivot` / target / `cleanup` / `file_transfer` companion events | deterministic rule (implicit-high) | `targetExtractors` (Recognizer) | +| **Classifier** | 1 event/output → label + confidence | `loot-detector`, `scope-monitor`, `command-tagger` | `loot`, `scope_violation`, stamped tags | pattern/entropy → `high\|medium\|low` | `lootPatterns` / `commandTags` (Recognizer) · `detectionPatterns`/`monitors` (Labeller) | +| **Monitor** | world state over time → transition event | `ip-monitor`, opsec-state monitor | `system.ip_transition`, `system.opsec_state_changed` | observed state delta | — (built-in; a plugin path would be a gated Monitor) | +| **Correlator** | N events → causal edge | `causes-resolver` | `_causes` links (start→end via flow_id) | structural match (flow_id/session) | — | + +**Completeness:** all ten inference-layer files map to exactly one of four roles. +The axis (input→output shape) is exhaustive because a detector can only consume +one event, one blob, ambient state, or a set of events — there is no fifth input +class. + +## The §3 suggestion contract (what EVERY role must emit) + +This is the uniform output the role framework enforces — the payoff of the +decomposition: + +- **Inferred, never authoritative:** does not mutate the source event's data; lives + in the derived/review layer or as a typed detection event. +- **Confidence-scored:** an explicit `confidence` (loot already does this, + `loot-detector` L8/L17). Deterministic Extractors carry an implicit-high but + should still *state* it, not omit it. +- **Detector-attributed:** names which detector fired (`target-extractor` already + stamps `extractor_name`; every role should carry the analog). +- **Operator-promotable:** one-click promotion to an authoritative marker; rendered + visually distinct from operator assertions (dashed vs solid, §3). + +## Per-role reference (template) + +### 1. Extractor — pull a structured fact from one command + +- **Input → output:** a command string → a structured record (tool, subtype, via, + route, target). Pure function, no side effects (`technique-tagger` L13, + `pivot-detector` L32 are the model). +- **Emits:** a first-class companion event (`pivot`/`cleanup`/`file_transfer`) or a + resolved target with provenance. +- **Confidence:** deterministic rule → implicit-high; **state it explicitly** (gap: + `pivot-detector`/`technique-tagger` currently omit a confidence field though §3 + says the *interpretation* "this ssh -D is a SOCKS pivot" is inferred). +- **Cost:** one regex pass per command. Negligible. +- **Test seam:** `detectX(command) → record | null` over sample-command fixtures — + already the shape; keep it pure. +- **Instances:** pivot, cleanup, file-transfer, target extraction. Plugin face: + `targetExtractors`. + +### 2. Classifier — label an event/output with confidence + +- **Input → output:** an event or output blob → a label + `confidence`. +- **Emits:** `loot` (type + confidence), `scope_violation` (verdict), stamped tags. +- **Confidence:** pattern/entropy/heuristic → `high|medium|low` (`loot-detector` + L8). The **scope verdict is a Classifier output** — inferred, so it must be + promotable, not silently authoritative. +- **Cost:** regex/entropy per event; a model-backed classifier (injection) belongs + in a 🔴 plugin Monitor, not the built-in hot path. +- **Test seam:** `classify(input) → {label, confidence} | null` over labelled + fixtures (real / not). +- **Instances:** loot, scope verdict, technique/command tags. Plugin face: + `lootPatterns`, `commandTags` (deterministic), and the Labeller role + (`detectionPatterns` 🟢 / `monitors` 🔴) for AI-era injection labels. + +### 3. Monitor — watch state, emit on transition + +- **Input → output:** ambient world/system state sampled over time → a transition + event only when it changes. +- **Emits:** `system.ip_transition`, `system.opsec_state_changed` (VPN/DNS/MAC/ + hostname). `IPMonitor extends EventEmitter` (`ip-monitor` L122) is the model. +- **Confidence:** the delta is observed (a fact *that state changed*); the + *interpretation* (safe vs risky transition) is the inferred part. +- **Cost:** polling (30 s opsec, hysteresis on IP) — standing, so tune intervals; + matches §2's ambient-is-opt-in stance for anything heavier. +- **Test seam:** `next(prevState, sample) → transition | null` — pure over state + pairs; the poller is a thin wrapper. +- **Instances:** IP monitor, OPSEC monitor. + +### 4. Correlator — relate events into a causal edge + +- **Input → output:** two or more events → a causal/relational link between them. +- **Emits:** `_causes` edges (a `command_end` refers to its `command_start`; an + `http_request_end` to its `http_request_start` via `flow_id` — + `causes-resolver` L58/L75). +- **Confidence:** usually a **structural** match (flow_id/session/pid), so + high-certainty; a heuristic correlation (timing-only) would be inferred and must + say so. +- **Cost:** a small in-memory cache keyed by the linker field; O(1) per event. +- **Test seam:** `resolveIncomingCauses(agentType, data) → sourceIds` over an + event sequence (`_resetCausesResolver` exists for test isolation, L104). +- **Instances:** start→end causal linking. Candidate future: pivot→command, + loot→exfil chains. + +## The cross-link that matters + +**The detector layer and the plugin Recognizer/Labeller roles are one taxonomy +seen from two sides.** Built-in `loot-detector`/`target-extractor` are the core's +Classifier/Extractor; plugin `lootPatterns`/`targetExtractors`/`detectionPatterns` +are the *contributed* Classifier/Extractor. So a new detection need has one +decision — **core or plugin** — and the same role either way. This is why the two +docs share vocabulary. + +## Gaps this framework surfaces + +| # | Gap | Role | Fix | +|---|---|---|---| +| 1 | Extractors omit an explicit confidence field | Extractor | add `confidence` (implicit-high) so §3's "this is inferred" is visible, not assumed | +| 2 | scope verdict is emitted as a plain violation, not a promotable suggestion | Classifier | render the verdict as a §3 suggestion with one-click operator promotion | +| 3 | no built-in inferred-`detection` event type | Classifier/Labeller | define the shared §3 suggestion shape (confidence, attribution, promote) — same gap as `PLUGIN-ROLES.md` gap #3 | +| 4 | Monitor has no plugin path | Monitor | if third-party state monitors are wanted, a gated plugin Monitor (mirrors 🔴 `monitors`) | +| 5 | Correlator is single-purpose (start→end) | Correlator | generalize to declare linker-field + relation, enabling pivot→command / loot→exfil chains | + +## Cross-references + +- The method: `DECOMPOSITION-METHOD.md` +- Plugin-side twin roles: `PLUGIN-ROLES.md` (Recognizer, Labeller) +- The law this layer carries: `DESIGN-PRINCIPLES.md` §3 +- Event shapes: `event-schema.md` diff --git a/docs/EVENT-TYPE-VOCABULARY.md b/docs/EVENT-TYPE-VOCABULARY.md new file mode 100644 index 00000000..08939b74 --- /dev/null +++ b/docs/EVENT-TYPE-VOCABULARY.md @@ -0,0 +1,101 @@ +# Event-Type Vocabulary — origin, authority, and timeline identity + +Written 2026-08-13. Applies `DECOMPOSITION-METHOD.md` to the `agent_type` +vocabulary — the set of event kinds and **how each declares its timeline +presentation and its §3 authority**. This is the seam that ties three frameworks +together: a **capture plugin** (`PLUGIN-ROLES.md`) emits events; each event's +`agent_type` must claim a **timeline identity** (`TIMELINE-ELEMENTS.md` channels); +and that identity must state whether the event is a **fact or a suggestion** +(§3). `event-schema.md` owns the field definitions; this doc is the classification ++ the pairing rule. + +## The load-bearing rule (why capture plugins need this) + +**A capture plugin that emits a new event kind MUST pair its `capture` with an +`eventTypes` contribution.** Without it, the event's `agent_type` is unknown to the +registry (`event-registry.ts`) and the timeline drops it into the generic "other" +bucket — no lane, no colour, no glyph, no authority. So "capture" and "how it looks +on the timeline" are **one decision**, not two: emitting without declaring is the +most common way a plugin's evidence becomes illegible on the reconstruction +surface. + +``` +Parser/Tailer/Tee (PLUGIN-ROLES) → emits events with agent_type X + │ + MUST pair with ─────────────► eventTypes: { agentType: X, label, lane, color, icon } + │ (registerEventTypes → event-registry) + ▼ + timeline channels (TIMELINE-ELEMENTS): lane, colour, glyph + + §3 authority (fact vs inferred) +``` + +## Classification — origin axis (mechanism) + +Every `agent_type` maps to exactly one **origin** — where the event comes from — +which also fixes its default authority (§3): + +| Origin class | agent_types | Authority (§3) | Default timeline identity | +|---|---|---|---| +| **Operator-authored** | `marker` | **authoritative** (operator assertion) | own lane, solid | +| **Primary-capture** | `shell`/`terminal`, `http`, `http_navigation`, `browser`, `scanner`, `dns`, `screenshot`, `clipboard`, `process` | **authoritative** (a thing actually happened) | source lane / target lane, solid | +| **Agent** | `agent` (tool-calls) | authoritative (the agent did call the tool) | agent lane, solid | +| **Detector-derived** | `loot`, `pivot`, `cleanup`, `file_transfer` | **inferred** (interpretation, §3) | own lane, **dashed** ✅ | +| **System / meta** | `system` (`recording_paused`, `config_changed`, `sanitized`, `*_pruned`, `ip_transition`, `opsec_state_changed`) | authoritative (system fact / drift signal) | system lane / ribbon, solid | +| **Split-authority** | `system`/`scope_violation` | **per event** — `fact` for an excluded target, `inferred` for a proximity match | diamond, stroke set by `data.authority` | + +**Correction (2026-08-14, found while implementing K1).** An earlier revision of +this table filed `scope_violation` under detector-derived. Two things were wrong: +it is a **`system` subtype**, not an agent_type, and its authority is **not fixed +by type** — hitting an explicitly excluded target is an observed rule match +(`fact`), while hitting a neighbouring host is a proximity judgement +(`inferred`). See `ALERT-ROLES.md` B.5. + +That single case is why authority resolution is **per-event first**: no +type-level default can be right for a type that legitimately emits both. The +type-level default (`EventTypeDef.authority`, and the built-in table in +`core/authority.ts`) is the fallback, never the override. +| **Plugin-contributed** | any of the above, via a plugin | inherits the class it emits into | **must be declared** via `eventTypes` | + +**Completeness:** an event can only be *authored* by the operator, *captured* from +a medium, produced by the *agent*, *derived* by a detector, emitted by the +*system*, or *contributed* by a plugin — six origins, no seventh. Every existing +`agent_type` lands in exactly one. + +## The two axes + +- **Origin** (mechanism) → the class above; also sets **default authority**. +- **Authority** (§3) → authoritative vs inferred, which **must** show as solid vs + dashed on the timeline. This is the same authority axis as `DETECTOR-ROLES.md` + and `TIMELINE-ELEMENTS.md` — one law, three surfaces. + +## Timeline identity contract (per `agent_type`) + +Declared by `EventTypeDef { agentType, label, lane?, color?, icon? }` +(`event-registry.ts`), built-in or plugin-contributed. Maps onto the timeline +channels: + +- `lane` → the **Lane** channel (should resolve under the target/phase axis, §8 — + see gap #3). +- `color` → the **Colour** channel (source-type encoding — see gap #4). +- `icon` → the **Glyph** channel. +- ~~**missing** `authority`~~ → ✅ resolved per event, defaulting by type (gap #2) + (gap #2). + +## Gaps this framework surfaces + +| # | Gap | Fix | +|---|---|---| +| 1 | `eventTypes` is **optional** — a plugin can emit a new `agent_type` with no identity → "other" lane | make a new-emitting `capture`/`tailer` **require** a paired `eventTypes` (load-time warn/lint); surface undeclared kinds in Settings ▸ Plugins | +| 2 | ~~`EventTypeDef` has no `authority` field~~ | ✅ **done (K1 slice).** `EventTypeDef.authority` + `EventTypeContribution.authority`; resolved by `core/authority.ts`, stamped into the hashed row by `insertEvent`, read by `lib/dotShape.ts` for the solid/dashed split. Promote-to-marker beyond phase is still open. | +| 3 | `lane` is an **open string** — a plugin can invent a lane that doesn't map to the target/phase axis | reconcile the lane vocabulary with the `lanesForAxis` model (§8): a plugin lane is a *source* lane (a filter), never a new organizing axis | +| 4 | `color` is per-`agent_type`, but the timeline binds **Colour ← source-type**; a plugin picking arbitrary colours collides with the palette | derive plugin colours from the source-type palette, or document the collision rule | +| 5 | vocabulary is documented in `event-schema.md` but the **origin/authority classification** isn't formalised there | keep this doc as the classification; `event-schema.md` stays the field reference | + +## Cross-references + +- The capture roles that emit events: `PLUGIN-ROLES.md` (Parser, Tailer, Tee) +- The channels an identity maps to: `TIMELINE-ELEMENTS.md` +- The inferred-origin detectors: `DETECTOR-ROLES.md` +- Built-in vs plugin capture line: `CAPTURE-SOURCE-TAXONOMY.md` +- Field reference (source of truth): `event-schema.md` +- Fact vs suggestion: `DESIGN-PRINCIPLES.md` §3 diff --git a/docs/OFF-CHAIN-CONTENT-STORES.md b/docs/OFF-CHAIN-CONTENT-STORES.md new file mode 100644 index 00000000..9dd44c06 --- /dev/null +++ b/docs/OFF-CHAIN-CONTENT-STORES.md @@ -0,0 +1,105 @@ +# Off-Chain Content Stores — one pattern, two shapes + +Written 2026-08-13. Applies `DECOMPOSITION-METHOD.md` to the heavy content RedLog +keeps *off* the hash chain. Today this is **four stores** across separate +implementations — `.cast` recordings, agent transcripts, io_ref bodies, +screenshots — with separate directories, **three live retention paths** (io_ref is +spec-only, never implemented), and parallel reference conventions. This doc shows +they are **one off-chain-content pattern in two shapes (Blob / Stream)**, unified +by a single invariant and a single lifecycle engine (per the cross-session +consensus + code audit with the transcript/log sessions, 2026-08-13). + +## The core invariant (from `SPEC-IO-SIDECAR.md`) + +**Bytes never enter the chain — only their `sha256`.** The event carries a +reference + digest; the bytes live in an off-chain store; a pruned/absent object +reads as **pruned, not tampered** (`redlog-verify` semantics). This is the same +invariant already stated for the io sidecar — the decomposition just recognises it +governs *all* heavy content, not only HTTP bodies. + +## The two roles (mechanism axis = content shape) + +``` +Is the content one opaque object (read whole or by byte-range)? → BLOB +Is it a time-indexed sequence (read by time-slice)? → STREAM +``` + +## Master table + +| Store | Role | Addressing | Dir / layout | Reference on event | Read | Retention today | +|---|---|---|---|---|---|---| +| **io_ref bodies** | Blob | **content-addressed** | `io/.bin`, dedup by digest | `io.*` ref + `sha256` | whole or ranged (`io:read`, path-validated) | **spec-only — never implemented** (no runtime code; only `SPEC-IO-SIDECAR.md` et al.) | +| **Screenshots** | Blob | **path-addressed** | `screenshots/${ts}_${trigger}.jpg`; `sha256` = integrity only, not location/dedup (`screenshot-agent.ts`) | `filename` (basename) + `sha256` | whole (image) | `screenshots.keepDays` → `screenshot_pruned` | +| **Cast recordings** | Stream | path-addressed | `casts/` per session; integrity `castSha256` | `castPath` + `castSha256` | **time-slice** `readCastSlice(path, startMs, endMs)` (asciicast v2 JSON-lines) | `terminal.castKeepDays` → `cast_pruned` | +| **Agent transcripts** | Stream | path-addressed | `agent-transcripts/${agentKind}-${sessionId}.jsonl`, append-only; integrity `cumulative_sha256` | `snapshot_path` + `cumulative_sha256` (`transcript_snapshot` event) | whole / tail | `agentTranscripts.keepDays` → `agent_transcript_pruned` | + +## The two classifying axes + +- **Shape** (mechanism) → **Blob** (opaque object) vs **Stream** (time-indexed + sequence). This is the role. +- **Addressing** → **content-addressed** (dedup by digest, location = the hash) vs + **path-addressed** (per-session/per-capture name, digest is integrity only). + Independent of shape. + +**These two axes give three valid combinations, not two** (per the code audit, +2026-08-13): + +| | content-addressed | path-addressed | +|---|---|---| +| **Blob** | io_ref bodies | screenshots | +| **Stream** | — (a time sequence *is* its position; content-addressing is nonsensical) | `.cast`, agent transcripts | + +So the honest classification is **Blob-content / Blob-path / Stream-path** — see +gap #3. + +## The uniform contract every off-chain store must satisfy + +The payoff — three ad-hoc stores collapse to one interface + two shape adapters: + +1. **Digest on chain, bytes off chain.** The event holds `ref` + `sha256`; the + store holds bytes. Verify checks bytes-match-digest; a missing object is + *pruned*, never *tampered*. +2. **Referenced, not embedded.** The store resolves `ref → bytes` behind a + **path-traversal guard** (stay inside the store dir — `io:read` already does + this; the guard should be shared, not re-implemented per store). +3. **One lifecycle engine.** hot → warm (compress) → pruned, **refcount-gated**, + **scope/marker as pin** — all from `SPEC-SCOPE-AWARE-LIFECYCLE.md`. Every store + plugs into *one* retention/rotation engine, not three keep-day knobs. +4. **Shape-appropriate read.** Blob → whole or byte-range; Stream → time-slice. + Both **lazy** (never loaded to scroll the timeline; a preview/glyph stands in, + per `TIMELINE-ELEMENTS.md`). + +## Completeness + +Heavy content is either **one opaque object** (Blob) or a **time-indexed +sequence** (Stream) — there is no third shape. Every current store maps to one +role. Future heavy captures slot in by shape without a new subsystem: + +- **Blob:** pcap files, memory dumps, downloaded artefacts, binary loot. +- **Stream:** screen-recording video, keystroke timelines, packet streams. + +## Gaps this framework surfaces + +| # | Gap | Fix | +|---|---|---| +| 1 | **io_ref is spec-only** — no runtime code exists (grep hits docs only); it is *never implemented*, not "wired but retention-less" | implement the Blob store, then wire its keep-window as the first store to prove the unified interface | +| 2 | **Three parallel retention branches** — `config.terminal.castKeepDays` + `config.screenshots.keepDays` + `config.agentTranscripts.keepDays`, each a separate branch in `retention.ts` `sweepRetention()` | a `contentStore..keepDays` registry turns 3 branches into **one loop over registered stores** — a real code convergence, not just a doc one | +| 3 | **Addressing is three-way, not two** — the audit found screenshots are path-addressed (filename = `${ts}_${trigger}`, `sha256` integrity only), not content-addressed | classify deliberately as **Blob-content / Blob-path / Stream-path** (Stream-content is nonsensical); document why each store sits where it does | +| 4 | **`isInsideDir()` traversal guard duplicated in 3 places** (tailer sidecar, screenshot delete, terminal replay) | extract one shared traversal guard + one shared "bytes-match-digest / pruned≠tampered" verify, used by every adapter | +| 5 | **Compression/pin not applied uniformly** | the warm-compress + scope-as-pin from the lifecycle spec should cover `.cast`, agent transcripts (both compress well) and screenshots, not just io bodies | +| 6 | **Four stores, one missing from the taxonomy until now** — agent transcripts (`transcript_snapshot` + `cumulative_sha256`) is a Stream store parallel to `.cast` | route it through the same Stream adapter; it already has the `snapshot_path` + `cumulative_sha256` shape | + +## Note on formats + +The `.cast` on-disk format (asciicast v2 today; a v3 greenfield move is under +discussion in the transcript session) is the **Stream adapter's concern**, not this +layer's — the decomposition is about the store contract (digest, ref, lifecycle, +read), not the byte format. See that session for the format decision. + +## Cross-references + +- The Blob store's detailed spec: `SPEC-IO-SIDECAR.md` +- The lifecycle engine all stores share: `SPEC-SCOPE-AWARE-LIFECYCLE.md` +- Which capture items produce which content: `CAPTURE-SOURCE-TAXONOMY.md` +- How content surfaces on the timeline (glyph/preview, lazy): `TIMELINE-ELEMENTS.md` +- The method: `DECOMPOSITION-METHOD.md` diff --git a/docs/PLUGIN-ROLES.md b/docs/PLUGIN-ROLES.md new file mode 100644 index 00000000..43a4e8f5 --- /dev/null +++ b/docs/PLUGIN-ROLES.md @@ -0,0 +1,266 @@ +# Plugin Roles — a categorization for unambiguous development + +Written 2026-08-13. The classification layer above `docs/plugin-development.md` +(mechanics/trust) and `SPEC-AI-ERA-PLUGINS.md` (the four AI-era gaps). Its job: +**a developer with a capture/detection need looks up one row, and knows exactly +which contribution type, which trust tier, what event to emit, and what it costs +locally — before writing a line.** + +## The one principle everything derives from + +**Built-in captures the *medium* (shell / HTTP / DNS / agent tool-call / screen / +clipboard / process). A plugin performs a *role* over that medium.** So the first +question is never "what tool is this" but "**what am I doing, to which layer**" +— that answer is the role, and the role fixes the contribution and the tier. + +## Two classifying axes + +- **Axis 1 — what layer does it act on?** → picks the *role*. +- **Axis 2 — does it run code *inside* RedLog?** → picks the *trust tier* + (🟢 data / out-of-process = trust-free; 🔴 in-process code = isolated, + capability-scoped, human-approved). + +## Decision tree (pick your role) + +``` +Are you teaching RedLog to *understand* a tool with only data/regex, + over events it already captures? → RECOGNIZER 🟢 +Turning a tool's *structured output file* into typed events? → PARSER 🟢 +Following an *external log/stream* continuously? → TAILER 🟢* +Sitting *between two external parties*, forwarding+recording? → TEE 🟢 +*Annotating* already-captured events with a judgement? → LABELLER 🟢/🔴 +Giving an *AI agent* tools to operate RedLog? → TOOL PROVIDER 🔴 +Producing a *deliverable* from the evidence store? → EXPORTER 🔴† +``` +`*` tailers: **bundled-only today** (user-plugin tailers need isolation work, +`types.ts` L144). `†` exporters: contribution **reserved, not yet implemented**. + +## Master table + +| Role | Acts on | Contribution(s) | Tier | Emits | Local cost | AI-era instance | +|---|---|---|---|---|---|---| +| **Recognizer** | substrate's reading of captured events | `targetExtractors`, `commandTags`, `lootPatterns`, `redaction`, `eventTypes` | 🟢 data | tags/targets on existing events; no new event | per-event regex, negligible | recognize a new AI-tool's targets/fields | +| **Parser** | a tool's completed output file | `capture` (+`eventTypes` +`targetExtractors`) | 🟢 | typed `scanner`/`scan_result` events | one-shot parse | **Gap 1** structured scan (nmap XML, nuclei JSON) | +| **Tailer** | an external long-running log/stream | `tailers` | 🟢* | events per line/record | continuous file-follow | **Gap 2** C2 logs (Sliver, CS) | +| **Tee** | a channel between two external parties | `capture` (out-of-process proxy) | 🟢 | `agent`/`tool_call` events | one hop per call | **Gap 3** third-party MCP tee | +| **Labeller** | already-captured event stream | `detectionPatterns` (🟢, proposed) / `monitors` (🔴, reserved) | 🟢/🔴 | inferred `detection` suggestions (§3) | regex per-event / LLM call | **Gap 4** injection / hijack labels | +| **Tool provider** | RedLog itself (as MCP server) | `mcpTools` | 🔴 | tools the agent calls; events attributed to operator | RPC | RedLog-as-tool for the operator's AI | +| **Exporter** | the evidence store | `exporters` | 🔴† | a deliverable artifact | store scan | scope-sanitized bundle (see lifecycle spec) | + +## Completeness — every contribution maps to exactly one role + +The roles are cut along the **contribution mechanism**, not along tools, so they +are provably exhaustive over the plugin API. Every slot in `PluginContributes` +(`types.ts` L130–146) maps to a role: + +| Contribution (`PluginContributes`) | Role | Tier | +|---|---|---| +| `targetExtractors` | Recognizer | 🟢 | +| `commandTags` | Recognizer | 🟢 | +| `lootPatterns` | Recognizer | 🟢 | +| `redaction` | Recognizer | 🟢 | +| `eventTypes` | Recognizer (also a Parser helper) | 🟢 | +| `capture` — parses a completed output | Parser | 🟢 | +| `capture` — forwards in-band | Tee | 🟢 | +| `tailers` | Tailer | 🟢* | +| `monitors` *(reserved)* | Labeller (semantic) | 🔴 | +| `detectionPatterns` *(proposed)* | Labeller (regex) | 🟢 | +| `mcpTools` | Tool provider | 🔴 | +| `exporters` *(reserved)* | Exporter | 🔴† | + +So **7 roles cover all 10 contribution slots** (the 5 Recognizer data-contributions +collapse into one role; `capture` splits into Parser vs Tee by whether it parses a +finished artifact or forwards a live channel). Anything a plugin can express today +is one of these roles. + +**The boundary (stated honestly):** the roles are complete *over the contribution +surface*, not over every imaginable need. A need that fits no contribution type is +not a plugin yet — it is an **API gap**, and the known ones are listed at the +bottom. When the surface grows, the new contribution gets a new role (or extends +one) — the framework is complete-and-extensible, not frozen. Roles are also +**for plugins only**: built-in substrate capture (the 15 medium items in +`CAPTURE-SOURCE-TAXONOMY.md`) is deliberately *not* a role. + +## Cross-cutting rule — emitting roles must claim a timeline identity + +The event-producing roles (**Parser, Tailer, Tee**, and a Labeller's detections) +emit events with an `agent_type`. **Each must pair with an `eventTypes` +contribution** so the event claims a timeline identity (lane / colour / glyph) and +its §3 authority — otherwise the timeline drops it into the generic "other" lane +and the plugin's evidence is illegible on the reconstruction surface. "Capture" +and "how it looks on the timeline" are one decision. See `EVENT-TYPE-VOCABULARY.md`. + +--- + +## Per-role reference (development template) + +Each role below follows the same template — **Layer · Contribution · Tier & why · +Event contract · Local cost · Install · Test seam · Instance** — so building a new +plugin is filling in a known shape. + +### 1. Recognizer 🟢 — teach the substrate, with data only + +- **Layer:** the interpretation of events the substrate already captured (a shell + command, an HTTP flow). Adds no new capture. +- **Contribution:** `targetExtractors` (pull a host from a bespoke tool's cmd), + `commandTags` (stamp fields — MITRE, tool-category), `lootPatterns` (new secret + format), `redaction` (allow/deny), `eventTypes` (give an `agent_type` a lane, + colour, icon). Exact shapes: `types.ts` L45–98. +- **Tier & why:** 🟢 — pure data the app reads; loads automatically; cannot touch + the chain. Zero trust cost, so **the long tail lives here** — most "support tool + X" needs are a `targetExtractor` + `eventType`, nothing more. +- **Event contract:** does not emit; annotates. `targetExtractors` stamp + `extractor_name` for audit traceability (`types.ts` L77). +- **Local cost:** regex per matching event. Negligible. +- **Install:** none — declarative, active on enable. +- **Test seam:** the pattern set is data; test the extractor/tagger as a pure + match over sample commands. +- **AI-era instance:** recognizing a new AI-agent CLI's target/host argument, or + tagging its subcommands with ATT&CK techniques. + +### 2. Parser 🟢 — a tool's structured output → typed events + +- **Layer:** a tool's *completed* structured artifact (nmap `-oX`, nuclei + `-json`, httpx). The substrate already logged the *command*; the Parser turns + the *result file* into typed findings. +- **Contribution:** `capture` (`CaptureContribution`, `types.ts` L100) that parses + and POSTs, **+** `eventTypes` for the timeline identity, **+** + `targetExtractors` for hosts/ports. +- **Tier & why:** 🟢 — an out-of-process script that POSTs to the authenticated + local API like any client; unprivileged, can't touch the chain. +- **Event contract:** one event per host/finding — + `agent_type: 'scanner'`, `subtype: 'scan_result'`, parsed fields (port, + service, template-id, severity). **Severity/loot are inferred suggestions + (§3)**, never authoritative. +- **Local cost:** parse runs once at scan completion. Output files can be large, + but only parsed events enter the chain (raw file optionally io-sidecar'd). +- **Install:** `installMethod: 'manual'` (wrap the tool invocation) or a Tailer + follow of the output directory. `manualSteps` carry the wrap command. +- **Test seam:** the parser is a pure `parse(rawOutput) → Event[]`; test against + captured sample XML/JSON fixtures before it ever POSTs. +- **AI-era instance:** **Gap 1** — a bundled `scan-parsers` pack. + +### 3. Tailer 🟢* — follow an external long-running channel + +- **Layer:** an external channel RedLog isn't sitting on — a C2 server's log/DB, + a scan-output directory being appended to. +- **Contribution:** `tailers` — a module exporting a `TailerAdapter` + (`{ agentKind, transcriptGlob, … }`, `tailer-registry.ts`). The host wires it + into `tailer-host`. +- **Tier & why:** 🟢, but **bundled-only today** — user-plugin tailers need the + isolation work slated for v0.8.3+ (`types.ts` L144). A tailer runs continuously + and reads an external file, so the eventual user-plugin path will be gated. +- **Event contract:** one event per line/record — e.g. `agent_type: 'scanner'` + `subtype: 'c2_checkin'`, or `pivot` for a new route. +- **Local cost:** continuous file-follow (inotify/poll) — low but standing; the + exposure window is the live tail, matching §2's "ambient = opt-in." +- **Install:** config the log path per tool; `manualSteps` document where each + framework writes its log. +- **Test seam:** the adapter's line→event mapping is pure; test over recorded log + fixtures. +- **AI-era instance:** **Gap 2** — a bundled `c2-tailers` pack. + +### 4. Tee 🟢 — record a channel by sitting in-band + +- **Layer:** an in-band channel between two *external* parties — the operator's AI + client and a third-party MCP server (HexStrike, PentestMCP). +- **Contribution:** `capture` — an out-of-process proxy the client points at, + which forwards to the real server **and** POSTs each call to RedLog. +- **Tier & why:** 🟢 — the proxy is a separate process; it can't stall RedLog and + can't touch the chain (it POSTs like any client). +- **Event contract:** `agent_type: 'agent'`, `subtype: 'tool_call'` / + `'tool_result'`, with tool name, input, and an output digest. +- **Distinguish from Tool provider (🔴):** a Tee **captures calls the agent makes + to someone else's server**; a Tool provider **exposes RedLog's own tools to the + agent**. Opposite directions — keep them named apart or they conflate. +- **Local cost:** one extra hop per tool-call — negligible interactively. +- **Install:** `manual` — repoint the AI client's MCP server URL/command at the + tee. Note: if the client has a native tool-call hook (Claude Code + `PostToolUse`), those calls are **already captured** — a Tee is only for + hookless clients. +- **Test seam:** the forward-and-tee logic is testable against a mock MCP server; + assert the emitted event equals the forwarded call. +- **AI-era instance:** **Gap 3** — needs a `examples/plugins/mcp-tee` shim (no new + API, but no pattern exists yet). + +### 5. Labeller 🟢/🔴 — annotate captured events with a judgement + +- **Layer:** the *already-captured* `http`/`agent` event stream. Adds + interpretation, not capture. +- **Contribution (two, by detectability):** + - **Regex/heuristic** (known payload markers, canary strings): 🟢 — a + `detectionPatterns` contribution (a **proposed** sibling of `lootPatterns`, or + a `kind` field on it: `lootPatterns` classifies secrets, this classifies + *behaviours*). Emits an inferred detection, not a secret. + - **Semantic** (paraphrased injection, multi-turn goal manipulation): 🔴 — a + `monitors` module that watches the event stream and emits inferred detections. + **`monitors` is reserved, not yet implemented** (`types.ts` L141) — this is + its first real use case. +- **Tier & why:** regex is data (🟢); a semantic monitor runs code and reads the + evidence stream, which is exactly what the 🔴 trust gate exists for. +- **Event contract:** an **inferred `detection` suggestion** (§3) — + confidence-scored, attributable to the detector, **visually distinct and + operator-promotable** (dashed vs solid, one-click promote). Never mutates the + source event; never authoritative. +- **Local cost:** regex per-event (cheap); a semantic monitor may call an LLM + (latency/cost the operator opts into) — runs in the isolated utilityProcess so + it can't block capture. +- **Install:** 🟢 pattern pack auto-loads; 🔴 monitor requires the trust gate. +- **Test seam:** regex over labelled event fixtures (real / not); for the monitor, + a pure `classify(event) → detection|null` behind the LLM call. +- **AI-era instance:** **Gap 4** — prompt-injection / tool-call-hijack labels. + +### 6. Tool provider 🔴 — expose RedLog to an AI agent + +- **Layer:** RedLog itself — the plugin makes RedLog an MCP server the operator's + agent can call. +- **Contribution:** `mcpTools` (module path). Runs in the isolated utilityProcess, + reaches RedLog only through the capability-scoped `ctx` RPC + (`read:events`/`write:events`/`read:findings`/`read:config`/`net:outbound`, + `types.ts` L119). +- **Tier & why:** 🔴 — it ships and runs code; gated by content-hash-pinned + consent (`trust.json`); changing code or requested capabilities revokes trust. +- **Event contract:** tools the agent invokes; any events appended are attributed + to the plugin's operator (`write:events`). +- **Local cost:** RPC per call; isolated so it can't stall the host. +- **Install:** trust gate + point the agent at RedLog's MCP endpoint. +- **Test seam:** each tool is a pure function of its args + a mocked `ctx`. +- **AI-era instance:** RedLog-as-tool inside an operator's autonomous pentest agent. + +### 7. Exporter 🔴† — transform the store into a deliverable + +- **Layer:** the whole evidence store. +- **Contribution:** `exporters` — **reserved, not yet implemented** (`types.ts` + L140). +- **Tier & why:** 🔴 — reads the full store (including sensitive bodies) and + produces an external artifact; gated. +- **Event contract:** appends a chained audit event for the export action. +- **Instance:** a scope-sanitized client-deliverable exporter (see + `SPEC-SCOPE-AWARE-LIFECYCLE.md` Part B) — its scope planner is the natural first + Exporter. + +--- + +## API gaps this framework surfaces (so future work is explicit) + +| # | Gap | Role blocked | Fix | +|---|---|---|---| +| 1 | `monitors` reserved, unimplemented | Labeller (semantic) | implement 🔴 `monitors`: isolated read of event stream + append of inferred detections | +| 2 | no `detectionPatterns` contribution | Labeller (regex) | add sibling to `lootPatterns`, or a `kind: 'secret'\|'detection'` field | +| 3 | inferred `detection` event type absent | Labeller output | define the §3 suggestion shape (confidence, detector attribution, promote) + its dashed-vs-solid rendering | +| 4 | user-plugin `tailers` bundled-only | Tailer | land the v0.8.3+ isolation so user tailers can be trust-gated, not blocked | +| 5 | no MCP-tee pattern/example | Tee | ship `examples/plugins/mcp-tee` + a stdio/HTTP-MCP shim | +| 6 | `exporters` reserved, unimplemented | Exporter | implement 🔴 `exporters`, first instance = scope-sanitized bundle | + +**Build order (from `SPEC-AI-ERA-PLUGINS.md`):** Parser + Tailer packs first (zero +core change, roles fully supported today), then Tee (shim + example), then +Labeller (grows the core API — sequence with the §3 promotion UI). Exporter tracks +the lifecycle spec. + +## Cross-references + +- Mechanics + trust gate: `docs/plugin-development.md` +- The four AI-era gaps in detail: `SPEC-AI-ERA-PLUGINS.md` +- Capture taxonomy + build/plugin line: `CAPTURE-SOURCE-TAXONOMY.md` +- Facts vs inferred suggestions: `DESIGN-PRINCIPLES.md` §3 +- Trust-tier rationale: `redlog-plugin-system` memory diff --git a/docs/PRODUCT-POSITIONING.md b/docs/PRODUCT-POSITIONING.md index 5a24771e..58e48563 100644 --- a/docs/PRODUCT-POSITIONING.md +++ b/docs/PRODUCT-POSITIONING.md @@ -25,18 +25,39 @@ explains it. > and hand a client or my own legal team something they can trust — **without > spending the engagement taking notes.** -Two halves, and both are load-bearing: - -1. **Complete + defensible** — the record has to survive scrutiny. That is the - hash chain, the OpenTimestamps anchor, the operator attribution, the signed - bundle. This is what separates RedLog from a scratchpad. -2. **Without taking notes** — capture has to be passive. The moment logging - depends on the operator remembering to log, the record has holes exactly - where the interesting things happened. This is why hooks are the backbone - and MCP is only the control plane (see `agent-integration.md`). - -Everything RedLog does is in service of one of these two. A feature that serves -neither is scope creep. +One core, one feeder — not two co-equal halves (see `DESIGN-PRINCIPLES.md` §1): + +1. **The core is evidentiary.** The reason RedLog exists is the tamper-evident + record a third party can verify: the hash chain, the OpenTimestamps anchor, + the operator attribution, the signed bundle. This is what separates RedLog + from a scratchpad, and it is what every feature ultimately answers to. +2. **Passive capture is the feeder, not the point.** Capture must be passive + because a record you must remember to make has holes exactly where the + interesting things happened, and a holey record is not defensible. So passive + capture is *necessary* — but its necessity is derived from the evidentiary + core, not independent of it. This is why hooks are the backbone and MCP is + only the control plane (see `agent-integration.md`). + +**Necessity test** (the yardstick for every feature): it earns its place iff it +strengthens the chain, feeds capture that would otherwise be lost, or serves the +live-OPSEC front door (below). Anything else is a *frozen secondary identity* or +scope creep. The full tiering and the design laws behind it live in +`DESIGN-PRINCIPLES.md`. + +## Two front doors, one reason to exist + +RedLog has two entry points, and conflating them mis-sizes half the roadmap: + +- **Evidence recorder** *(primary)* — the reason RedLog exists. Remove it and + what's left is an OPSEC widget, not RedLog. +- **Live-OPSEC HUD** *(co-headline)* — a genuinely standalone-usable second + front door. Some operators use *only* the HUD (external-IP / EXPOSED alarm, + live pivot chain, capture-health) and never record an engagement. It is a main, + marketed, on-its-own-usable feature — but its OPSEC scope is **frozen**: it + does not grow new OPSEC features. There is a first-class HUD-only runtime mode + (main window closed; monitors + overlay + tray keep running), and first-run + leads with the zero-setup HUD value while stating plainly that evidence + recording is the core. See `DESIGN-PRINCIPLES.md` §4. ## Who it's for @@ -66,6 +87,17 @@ the transcript tailer + shell hooks (passive, can't be forgotten) with MCP as an explicit control plane. This persona is why "hooks log, MCP operates" is a hard rule, not a preference. +### P4 — The HUD-only operator + +Uses RedLog purely as a live-OPSEC display — external-IP / EXPOSED alarm, live +pivot chain, capture-health at a glance — and may never record an engagement. +Enters through the second front door (above). Cares about: **zero setup, always +on top, out of the way.** This persona is why the HUD must run with the main +window closed and why first-run must give OPSEC value before asking for any +capture wiring. They are a first-class user, not an edge case — but the tray and +overlay always keep the evidence core one click away, because that is what RedLog +is *for*. + ### S1 — The evidence consumer *(stakeholder, not a user)* The client, the client's counsel, or the operator's own QA. Never opens the app. diff --git a/docs/README.md b/docs/README.md index 8c2bc6fa..08ffd266 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # RedLog Docs -Wiki-style index for **v0.9.4**. Every page is self-contained; follow the links inside each for cross-references. Downloads on the [releases page](https://github.com/guan4tou2/REDLOG/releases/latest). +Wiki-style index for **v0.11.7**. Every page is self-contained; follow the links inside each for cross-references. Downloads on the [releases page](https://github.com/guan4tou2/REDLOG/releases/latest). ## What RedLog is (and isn't) @@ -14,14 +14,21 @@ Wiki-style index for **v0.9.4**. Every page is self-contained; follow the links ## Product & planning +- **[Design principles](DESIGN-PRINCIPLES.md)** — the durable design laws behind RedLog: evidence-core, capture-broad-sanitize-later, the two-tier attribute law (record facts, never interpretation-as-fact), two front doors, accept-but-freeze, agents-are-a-capture-source, one-implementation control plane, timeline-as-reconstruction. The yardstick every "should we build X?" answers to; positioning and roadmap elaborate it. - **[Product positioning](PRODUCT-POSITIONING.md)** — the single source of truth for who RedLog is for (personas P1–P3 + the evidence-consumer stakeholder), the job to be done, the explicit non-goals, and where RedLog sits vs. Ghostwriter / RedEye / PwnDoc / manual oplogs. When positioning and roadmap disagree, this page wins. - **[Architecture](ARCHITECTURE.md)** — process/layer model, startup order, DB schema and migration strategy, the capture pipeline end to end, tailer host, evidence chain, export, plugin system, IPC conventions. Replaces the stale ASCII diagram in the README. - **[Roadmap](ROADMAP.md)** — what ships next and the v1.0 gate. Also states what is deliberately not planned. - **[Audit 2026-08-08](AUDIT-2026-08-08.md)** — standing defect list from a full-tree review: correctness, trust-model gaps, presentation, test coverage, doc drift. Each item tagged verified/reported. - **[UX & complexity audit 2026-08-10](UX-AUDIT-2026-08.md)** — persona-driven review of the renderer: where breadth has outrun the solo operator (Timeline 3,875 lines / Settings 2,681 lines), the first-run friction gap, and a prioritized backlog. Companion to the correctness audit above. +- **[UX audit 2026-08-13](UX-AUDIT-2026-08-13.md)** — per-view operability review (16 views): the resize/proportion gap (only one draggable pane app-wide), discoverability of hidden gestures, and cross-view consistency. Prioritized fix list. Feeds the two design docs below. +- **[Design system — UI/UX language & framework](DESIGN-SYSTEM.md)** — the single normative source for colour (the `soften`/HUD palette + residual bright-accent drift to fix), typography (unifying the two parallel size scales), icons, component patterns, and the resize/layout framework (``, collapsible sidebar, breakpoints). Codifies what's scattered across `tailwind.config.js`, `lib/hud.ts`, and `index.css`. +- **[Timeline discoverability — wheel & axis](DESIGN-TIMELINE-DISCOVERABILITY.md)** — deep-dive extending DESIGN-TIMELINE-INTERACTION §T2: turns the wheel-mode hint from reactive-flash into a persistent, always-true indicator (`wheelModeLabel` seam), and adds the axis-switching track A1–A3 (labelled source/target control, preserving lane filters under the target axis, the transition chip). - **[Timeline UX deep-dive 2026-08-10](UX-TIMELINE-2026-08.md)** — the F2 deep-dive: why the timeline feels unintuitive (invisible, overloaded, context-dependent gestures), what it is *not* (lanes/data are fine), and the T1–T6 simplification plan. - **[UX backlog — ticket specs](UX-BACKLOG-TICKETS.md)** — every finding (F1–F7, T1–T6) broken out into an independently implementable ticket: problem, proposed solution, acceptance criteria, pure test seam, effort, priority order. - **[Timeline interaction redesign draft](DESIGN-TIMELINE-INTERACTION.md)** — concrete UI proposal with ASCII wireframes for the legend (T1), the wheel-mode decision matrix (T2), and the active-modes row (T3), plus the build order. +- **[Settings IA reorg proposal (F3 structural)](DESIGN-SETTINGS-IA.md)** — proposal (for ratification) to reorganise Settings by necessity tier (Capture·Scope·Evidence·OPSEC·Advanced); two options, migration, acceptance, 4 open questions. +- **[Timeline axis spec (Phase C)](SPEC-TIMELINE-AXIS.md)** — executable spec for the flagship refactor: re-axis the timeline to target/phase (per DESIGN-PRINCIPLES §8/§9), the safe toggle-behind incremental strategy, the seams to build on the Phase B foundation, acceptance criteria, regression guards, and three open questions. +- **[io_ref sidecar spec](SPEC-IO-SIDECAR.md)** — full (untruncated) request/response bodies in a prunable on-disk sidecar with only the sha256 in the chain — the last unshipped piece of the v0.10.0 I/O-visibility work. Capture/read/export/retention/verify design, acceptance criteria, and build order. - **[Dev requirements — capture onboarding](DEV-REQUIREMENTS-capture-onboarding.md)** — spec + acceptance criteria for the Capture Readiness onboarding, and the red→green→integrate→cover TDD process every UX change should copy. - **[Timeline I/O visibility](timeline-io-visibility.md)** — design note (proposed). Which sources capture input/output today, where the gaps are, and the `io_ref` sidecar + transcript-view proposal that closes them. @@ -59,18 +66,39 @@ Drift-signals that make the log honest: `recording_paused` / `recording_resumed` - **[Audit trail](audit-trail.md)** — SHA-256 event chain + hourly OpenTimestamps anchoring + full re-walk verify (with wall-vs-monotonic clock-anomaly detection) + `.ots` bundle export + four-layer redaction. Threat model, what's detected, what isn't. - **[Event schema](event-schema.md)** — standard `agent_type` values and data keys (Ghostwriter-compatible). Read before designing a new event source. - **[Redaction design](redaction-design.md)** — design note (proposed, not yet implemented). Four-layer model: capture full → detect spans → mask in UI → sanitize on export. Why capture-time redaction is the wrong default for an evidence tool, and how the append-only chain makes deferred sanitization safe. Tracks [#6](https://github.com/guan4tou2/REDLOG/issues/6). +- **[Cloud share bundle](CLOUD_SHARE_BUNDLE.md)** — the shareable-bundle format: manifest shape, the redaction gate that runs before upload, sanitize counts, expiry, and the Worker's independent size cap. The remote-upload Snapshot in the delivery path. ## Extending RedLog - **[Plugin development](plugin-development.md)** — build a plugin: the manifest format, 🟢 declarative contributions (`lootPatterns`, `redaction`, `commandTags` for stamping MITRE/custom fields onto shell events, `targetExtractors`, `eventTypes`, `capture` integrations) and 🔴 privileged MCP tools, the capability-scoped `ctx` API, and the content-hash-pinned trust gate. RedLog ships with **no** `commandTags` — install per shop or let your SIEM tag downstream. Hot-reload via Settings ▸ Plugins ▸ Reload; drop plugin dirs into `~/.redlog/plugins/` (Open folder button). +## Design frameworks (capture & extension) + +The reusable decompositions behind capture and plugin design — read these before adding a capture source, a plugin, a detector, or a data-management policy. Each closes a subsystem into a small, **provably-complete** set of roles/tiers with a per-item template, so new work is "pick a row, fill the template" instead of a bespoke design each time. + +- **[The decomposition method](DECOMPOSITION-METHOD.md)** — *start here.* The six-step recipe these frameworks share (name the open subsystem → mechanism axis → tier axis → closed role set with an exhaustiveness proof → per-role template → named API gaps), the invariants of a *good* decomposition, and the map of which RedLog subsystems are done, next, or candidates. Read this to decompose a **new** subsystem the same way. +- **[Capture source taxonomy](CAPTURE-SOURCE-TAXONOMY.md)** — the built-in-vs-plugin line drawn once: the 15 substrate capture items (the *medium*: shell / HTTP / agent tool-call / screen / …), the AI-era tool surface mapped onto them, local-execution cost per item, and the **four-gate plugin lifecycle** (present → enabled → trusted → hook-installed). Start here to decide "does this ship built-in or as a plugin." +- **[Plugin roles](PLUGIN-ROLES.md)** — every plugin is one of **7 roles** (Recognizer, Parser, Tailer, Tee, Labeller, Tool provider, Exporter), cut along "what layer does it act on × does it run code inside RedLog." Proven exhaustive over the contribution API, each with a development template and the known API gaps. The lookup table for "what do I build." +- **[AI-era plugin blueprint](SPEC-AI-ERA-PLUGINS.md)** — the four gaps the 2026 tool surface opens (structured scan output, C2 logs, third-party MCP tee, injection labels), each specced as a role instance with contribution, tier, event contract, and build order. +- **[Detector roles](DETECTOR-ROLES.md)** — the same method applied to the inference layer: every detector is one of **4 roles** (Extractor, Classifier, Monitor, Correlator) emitting one uniform §3 suggestion. The built-in detectors and the plugin Recognizer/Labeller roles are one taxonomy seen from two sides (core vs contributed). +- **[Scope-aware lifecycle spec](SPEC-SCOPE-AWARE-LIFECYCLE.md)** — sanitize + retention + rotation unified: the chain stays a small immutable spine; heavy artifacts are refcounted, scope-prioritized, lifecycle-managed (hot → compressed → pruned). The per-capture-type coverage matrix states honestly where scope applies and where it can't. +- **[Control-plane faces](CONTROL-PLANE-FACES.md)** — the method's *one-role-plus-catalog* variant applied to §7: the canonical evidence-relevant op catalog, the single **Adapter** contract every face fills (transport + auth + op-subset, never logic), and the REST/MCP/IPC/CLI/shell/Codex faces classified — so "add an integration surface" is filling a contract and drift is detectable. +- **[Delivery targets](DELIVERY-TARGETS.md)** — how evidence *leaves* RedLog: every path is a **Snapshot** (complete, signed, verifiable artifact) or a **Stream** (real-time per-event feed), declaring a sanitize profile by audience and a shape-appropriate integrity contract — so no delivery path can leak un-sanitized or unverifiable evidence. Owns the delivery ops split out of the §7 control catalog. +- **[Timeline elements](TIMELINE-ELEMENTS.md)** — the method as a *structural lens* on the timeline: **7 encoding channels × 4 interaction modes**, each declaring §3 fact-vs-suggestion, restating the UX-TIMELINE complexity as channel/mode overloading. Defers to `SPEC-TIMELINE-AXIS.md` / `UX-TIMELINE-2026-08.md` / §8 for the design itself. +- **[Event-type vocabulary](EVENT-TYPE-VOCABULARY.md)** — the seam that ties capture, timeline, and §3 together: every `agent_type` classified by **origin** (authored / captured / agent / derived / system / plugin) and **authority**, plus the rule that a capture plugin emitting a new event kind **must** pair with an `eventTypes` contribution to claim its timeline identity (lane/colour/glyph) — or its evidence lands illegible in the "other" lane. +- **[Off-chain content stores](OFF-CHAIN-CONTENT-STORES.md)** — the heavy content kept *off* the chain (`.cast`, io_ref bodies, screenshots) is one pattern in two shapes — **Blob** (content-addressed, dedup, whole/ranged) and **Stream** (time-indexed, sliced) — under one invariant (digest on-chain, bytes off-chain, pruned≠tampered) and one lifecycle engine, replacing today's three ad-hoc stores. +- **[Decomposition backlog](DECOMPOSITION-BACKLOG.md)** — the ~30 "Gaps" rows from the nine framework docs, **deduplicated** into a prioritized backlog: two keystones (the §3 inferred-`detection` primitive; off-chain store unification), zero-core quick wins (`scan-parsers` pack, `mcp-tee` example), and the unification/contribution-surface work — each tagged code/doc and cross-referenced to the doc that surfaced it. + ## Agent skills (drop-in) - **[redlog-pentest](skills/redlog-pentest.md)** — Claude Code skill: hooks record, MCP operates; session start (`whoami` / `status` / `scope`), real-time findings, loot scanning, end-of-session `chain_anchor_now`. Copy to `~/.claude/skills/`. ## Project ops +- **[Test matrix](TESTING.md)** — every config option × value × expected behaviour, with the test that proves each one: the alert path end to end (IP verdict matrix A-1..A-9, range boundaries, settling, scope distance ladder D0–D3, HUD/dashboard display), the per-block config matrix, config merge + migration semantics, the manual QA that no unit test can reach, and the known gaps. Read this before changing a default or touching the alert path. - **[Releasing](releasing.md)** — two-phase cross-platform release via GitHub Actions (build matrix → single release job), plus the local `npm rebuild better-sqlite3` gotcha after packaging. +- **[Release checklist](RELEASE_CHECKLIST.md)** — the pre-release verification gates: what `npx playwright test` automates (§0 build + smoke, as of v0.9.4) and the manual checks that remain. Companion to Releasing. +- **[Windows setup](windows-setup.md)** — platform-specific install/build steps and gotchas for Windows; pairs with the [Windows compatibility audit](WINDOWS_COMPAT_AUDIT.md). ## Machine-readable diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fa09dbca..b5a7d180 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -73,14 +73,17 @@ Steps 1–2 of `timeline-io-visibility.md`: is never mistaken for absence of output. - Document `redlog-run` in the capture-health card, not only in a comment. -## v0.10.0 — I/O visibility, phase 2 ✅ shipped as v0.11.2 (except the sidecar) +## v0.10.0 — I/O visibility, phase 2 ✅ shipped as v0.11.2 (sidecar landed 2026-08-13) Steps 3–6: the `io_ref` sidecar and everything that reads it. -- `/io/` append-only store; `io:read` IPC; bundle export, - retention pruning (`system.io_pruned`) and `redlog-verify.py` support. -- `ScannerDetail` / `BrowserConsoleDetail`; raise `REDLOG_MAX_BODY` once - bodies are out of the event row. +- ✅ **io_ref sidecar** (`SPEC-IO-SIDECAR.md`): `/io/.bin` + content-addressed store (`src/core/io-store.ts`); full HTTP bodies sidecarred + at `POST /api/events` (option B) with only the digest chained; `io:read` IPC; + `ScannerDetail` lazy full-body load; bundle export copies `io/` + manifest; + retention pruning (`system.io_pruned`); `redlog-verify.py` re-hashes each + sidecar body (pruned ≠ tampered). Addon posts `*_body_full` (≤ `REDLOG_MAX_IO`, + default 2 MB) only when the 16 KB preview truncates. - Exchange view pairing `tool_call` ↔ `tool_result` and `command_start` ↔ `command_end`. - Transcript view + Markdown export. @@ -107,9 +110,11 @@ privileged path that skips the gate: Until now this roadmap has had one axis: correctness and trust. The 2026-08-10 review (`UX-AUDIT-2026-08.md`, `UX-TIMELINE-2026-08.md`) added a second — the primary persona's *success*, not just the log's honesty. The finding is that -RedLog is under-*sequenced*, not over-*featured*: P2/P3/expert surfaces are -presented to the solo operator (P1) at full weight from the first screen, and -the timeline's interactions are invisible, overloaded, and context-dependent. +RedLog is **mostly** under-*sequenced* rather than over-*featured*: P2/P3/expert +surfaces are presented to the solo operator (P1) at full weight from the first +screen, and the timeline's interactions are invisible, overloaded, and +context-dependent. The one genuine *over-build* is the plugin **marketplace** — +shelved as premature (see the necessity model below). Full ticket specs (F1–F7, T1–T6) live in `UX-BACKLOG-TICKETS.md`. The tension to hold: none of this is a new product direction — it is making the passive- @@ -141,6 +146,53 @@ Structural, sequenced later: header "View options" menu (T4), the remaining timeline seam extractions (T5), and lane virtualization (T6) — which is the same work as **V4** below and, once done, deletes the wheel-mode branch behind T2. +## Necessity model & design decisions (added 2026-08-11) + +A decision review settled what RedLog is, which features earn their place, and +how the kept ones should be designed. The durable laws live in +`DESIGN-PRINCIPLES.md`; the roadmap-affecting outcomes: + +**Verdicts on the feature surface** (necessity test: strengthen the chain ∨ feed +capture ∨ serve the live-OPSEC front door): + +- **Core, untouchable:** chain + OTS + signing + bundle + operator attribution; + the capture that feeds it; the detection/sanitize/prune machinery that makes + broad capture safe (loot, redaction, scope, io sidecar, retention, export + sanitize); the proxied browser (capture enabler); the IP/OPSEC/pivot monitors + (doubly necessary — feed the chain *and* the HUD). +- **Second front door, main but frozen:** the live-OPSEC HUD — first-class + HUD-only runtime mode; OPSEC scope does not grow. +- **Accept but freeze:** team affordances (multi-op, deconfliction, config sync — + no central server); local code plugins (no exporters/monitors); cloud share + (opt-in BYO). +- **Shelved:** the plugin **marketplace** — product, not platform. Do not invest + or market as core until a real distribution signal appears. +- **Kept as necessary extensibility:** declarative plugins. + +**Design direction for the kept features:** + +- **Timeline** = reconstruction/review surface (live goes to the HUD); reorganise + around a **target/phase axis** (source-type demotes to a filter, TargetView + folds in); timeline is the **event map**, transcript/exchange the **I/O + reader**, linked by drill-down. Reframes tickets T1–T6 and folds the + Timeline/TargetView/Transcript/Search sprawl toward three distinct-job views. +- **Capture items** obey the **two-tier attribute law**: facts are authoritative; + every interpretation (phase, ambiguous target, loot type, scope verdict, MITRE) + is a labelled, promotable *suggestion*, never written as fact. New: `phase` as + operator-marker segments with inferred dashed suggestions; conservative target + attribution with an "untargeted" catch-all. +- **Onboarding** = value-first (zero-setup HUD) with evidence stated as the core + and the Capture Readiness checklist as the invited next step. Extends F1/F1-b. +- **Settings** reorganise by necessity tier / front door (essentials prominent, + frozen behind "advanced", marketplace removed) — the structural half of F3. +- **Control plane** = one canonical implementation (REST) with thin, generated + adapters (MCP blessed, shell/Codex thin); minimal, evidence-relevant op set. + +These are not scheduled as a single release — they are the lens the F/T tickets +above are re-read through. Anything here that contradicts "report/ATT&CK/ +correlation stay downstream" would be a change to the non-goals, and there is +none: inference stays a suggestion, never authoritative output. + ## v0.12.0 — durability and scale | Item | Ref | diff --git a/docs/SPEC-AI-ERA-PLUGINS.md b/docs/SPEC-AI-ERA-PLUGINS.md new file mode 100644 index 00000000..3a33b83f --- /dev/null +++ b/docs/SPEC-AI-ERA-PLUGINS.md @@ -0,0 +1,139 @@ +# Spec — AI-Era Capture Gaps as Plugins (blueprint) + +Written 2026-08-13. Companion to `CAPTURE-SOURCE-TAXONOMY.md`, which concluded: +the 15 substrate capture items ship built-in; everything tool-specific is the +long tail → plugin. This blueprint takes the four AI-era gaps that survey named +and maps each onto RedLog's existing plugin contribution surface +(`src/core/plugins/types.ts`), stating for each: **which contribution type, +which trust tier, whether it is expressible today or needs an API extension, +the local-execution cost, and the install/config path.** + +## Contribution surface (what exists to build on) + +🟢 declarative (trust-free, data or out-of-process, can't touch the chain): +`lootPatterns`, `redaction`, `commandTags`, `targetExtractors`, `eventTypes`, +`capture` (hook that POSTs to the local API), `tailers` (long-running file/stream +follow; **user-plugin tailers are trust-gated**, bundled are free — `types.ts` +L144). + +🔴 privileged (isolated utilityProcess, capability-scoped `ctx`, hash-pinned +consent): `mcpTools`; **`monitors` and `exporters` are declared but reserved — +not yet implemented.** + +## Gap 1 — Structured scan output (nmap XML, nuclei JSON, httpx, …) + +- **Today the shell substrate captures the *command* and raw stdout; the + structured artifact (XML/JSON) is not parsed into typed findings.** +- **Contribution:** `capture` (parse-on-complete) **+** `eventTypes` (timeline + identity for `scan_result`) **+** `targetExtractors` (hosts/ports → targets) + **+** optional `lootPatterns` (secrets in banners). All 🟢. +- **Interface:** a `capture` script wraps or post-processes the tool + (`nmap -oX -`, `nuclei -json`), reads the structured output, and POSTs one + event per host/finding with `agent_type: 'scanner'`, `subtype: 'scan_result'`, + and the parsed fields (port, service, template-id, severity). Severity/loot are + **inferred suggestions** (§3), never authoritative. +- **Expressible today:** ✅ no API change. +- **Local cost:** parse runs once at scan completion — cheap; output files can be + large but only the parsed events enter the chain (raw file optionally io-sidecar'd). +- **Install:** `installMethod: manual` (wrap the tool invocation) or a `tailers` + follow of the scan-output directory. Ships as a bundled `scan-parsers` pack. + +## Gap 2 — C2 framework logs (Sliver, Cobalt Strike, Mythic) + +- **C2 beacons are an out-of-band channel RedLog isn't sitting on** — the + substrate items don't see them. Beacon check-ins, task results, and operator + commands live in the C2 server's own log/DB. +- **Contribution:** `tailers` — follow the C2 framework's log file (Sliver's + JSON log, CS Aggressor/beacon logs), emit `agent_type: 'scanner'` / + `subtype: 'c2_checkin'` / `pivot` events per line. 🟢, but **bundled-only + today** — user-plugin tailers need the isolation work slated for v0.8.3+ + (`types.ts` L144); a bundled `c2-tailers` pack ships now, user-authored C2 + tailers wait on that. +- **Interface:** the `TailerLike` adapter (`tailer-registry.ts`) — the plugin + supplies a module that tails a path and yields parsed events; the host wires it + to `tailer-host`. Config the log path per C2 tool. +- **Expressible today:** ✅ via `tailers`; the only friction is the user-plugin + trust gate (by design). +- **Local cost:** file-follow (inotify/poll) — low, continuous; the exposure + window is the standing tail, matching the §2 "ambient = opt-in" rule. +- **Install:** point the tailer at the C2 log path; `manualSteps` document where + each framework writes its log. Ships as a bundled `c2-tailers` pack. + +## Gap 3 — Third-party MCP tool-calls (HexStrike, PentestMCP, …) + +- **Nuance:** when the operator's AI client has a native hook (Claude Code's + `PostToolUse`), MCP tool-calls are **already captured** as `agent` events — + *no gap*. The gap is only clients **without** a hook. +- **Contribution:** a **MCP tee/proxy** the client points at, which forwards to + the real MCP server *and* POSTs each `tool_call`/`tool_result` to RedLog — a 🟢 + `capture` (out-of-process, unprivileged). Emits `agent_type: 'agent'` / + `subtype: 'tool_call'` with the tool name, input, and output digest. +- **Interface gap:** no new contribution *type*, but there is **no documented + MCP-proxy pattern or helper today** — this needs a first-class example + (`examples/plugins/mcp-tee`) and a thin stdio/HTTP-MCP shim the operator drops + between client and server. The shim is the deliverable, not a new API. +- **Distinguish from `mcpTools` (🔴):** `mcpTools` = RedLog *exposes tools to* + an agent (RedLog is the server). This gap is the inverse — capturing calls the + agent makes to *someone else's* MCP server (RedLog is a passive tee). Keep them + named apart in docs or they conflate. +- **Expressible today:** ⚠️ mostly — needs the proxy shim + example, no core change. +- **Local cost:** one extra hop per tool-call through the tee — negligible for + interactive use; the tee is out-of-process so it can't stall RedLog. +- **Install:** `manual` — reconfigure the AI client's MCP server URL/command to + point at the tee. `manualSteps` per client. + +## Gap 4 — Injection / tool-call-hijack semantic labels + +- **When the operator tests an AI *target*** (OWASP LLM/Agentic Top 10, MITRE + ATLAS), the exchange is already captured as `http` (API calls) + `agent` + (attacker transcript). What's missing is the **labelling** — "this request was + a prompt-injection attempt," "this response shows tool-call hijack." +- **Two-part answer, by detectability:** + - **Regex/heuristic subset** (known payload markers, canary strings): 🟢 a + pattern contribution in the `lootPatterns` mould, but emitting a + **`detection` suggestion** (inferred, confidence-scored, operator-promotable + per §3), not a loot/secret. Today `lootPatterns` only classifies secrets — + this needs a sibling `detectionPatterns` contribution or a `kind` field on + the existing one. + - **Semantic detection** (paraphrased injection, multi-turn goal manipulation): + a 🔴 **`monitor`** — watches the `http`/`agent` event stream and emits + inferred `detection` events. **`monitors` is reserved but not implemented** + (`types.ts` L141) — this gap is its first real use case. +- **Interface gap:** ⛔ needs (a) `monitors` implemented as a 🔴 contribution + (isolated, capability-scoped read of the event stream + append of inferred + detections), and (b) an inferred-`detection` event shape that is visually + distinct and operator-promotable (§3 — dashed vs solid, one-click promote). +- **Local cost:** regex subset is per-event and cheap; a semantic monitor may + call an LLM (cost/latency the operator opts into) — runs in the isolated + utilityProcess so it can't block capture. +- **Install:** 🟢 pattern pack loads automatically; 🔴 monitor requires the trust + gate (it runs code and reads the evidence stream — exactly what the gate is for). + +## Summary — build order + +| Gap | Contribution | Tier | Ready today? | Blocker | +|---|---|---|---|---| +| 1 Structured scan | `capture`+`eventTypes`+`targetExtractors` | 🟢 | ✅ | none — ship `scan-parsers` pack | +| 2 C2 logs | `tailers` | 🟢 (gated) | ✅ | none — ship `c2-tailers` pack | +| 3 3rd-party MCP | `capture` (MCP tee) | 🟢 | ⚠️ | needs proxy shim + `examples/plugins/mcp-tee` | +| 4 Injection labels | `detectionPatterns` (🟢) + `monitors` (🔴) | both | ⛔ | implement `monitors`; add inferred-`detection` event shape | + +**Sequence:** Gaps 1 & 2 first (pure packs, zero core change, immediate value). +Gap 3 next (a shim + example). Gap 4 last — it is the only one that grows the +core plugin API (`monitors` + the inferred-detection event type), and it should +land alongside the §3 promotion UI so a detection suggestion has somewhere to go. + +## Non-goals + +- No gap turns into a built-in capture source — the substrate already captures + the medium (shell/HTTP/agent); these are *parsers, tailers, tees, and + labellers* over captured evidence, correctly plugin-scoped. +- `monitors` must not become a back door to unlabelled authoritative assertions — + everything a monitor emits is an inferred suggestion (§3), never fact. + +## Cross-references + +- Capture taxonomy + build/plugin line: `CAPTURE-SOURCE-TAXONOMY.md` +- Trust tiers + contribution mechanics: `docs/plugin-development.md`, + `redlog-plugin-system` memory +- Two-tier facts vs suggestions: `DESIGN-PRINCIPLES.md` §3 diff --git a/docs/SPEC-IO-SIDECAR.md b/docs/SPEC-IO-SIDECAR.md new file mode 100644 index 00000000..289f9344 --- /dev/null +++ b/docs/SPEC-IO-SIDECAR.md @@ -0,0 +1,163 @@ +# Spec — io_ref Sidecar for Full Request/Response Bodies + +> **✅ Implemented 2026-08-13.** All five build-order steps landed: +> `src/core/io-store.ts` (`putBody`/`readBody`/`stampIoRefs`/`verifyBody`, 19 +> unit tests); sidecaring at `POST /api/events` (option B) + the mitmproxy addon +> posting `*_body_full` when the preview truncates; the `io:read` IPC +> (`ref`-only, so no arbitrary-path read); `ScannerDetail`'s "load full body" +> affordance; and bundle-export copy + retention `system.io_pruned` + +> `redlog-verify.py` re-hash (pruned ≠ tampered). Read cap `MAX_IO_READ_BYTES` +> = 8 MB; a body over the read cap or pruned is reported, never dumped as raw +> bytes. Retention knob: `config.io.keepDays` (default 0 = keep forever). + +Written 2026-08-12. The last unshipped piece of the v0.10.0 I/O-visibility work +(ROADMAP: "shipped as v0.11.2 except the sidecar"): store **full** captured HTTP +request/response bodies (and any other large capture payload) in a prunable +on-disk sidecar, keeping only their `sha256` in the hash chain. Today mitmproxy +bodies are inline, truncated previews (`request_body_preview` / `response_preview`, +capped at `REDLOG_MAX_BODY`, raised to 16 KB in b986441). This closes the "the +body was bigger than the cap, so you can't see what actually went over the wire" +gap without bloating the events table or the chain. + +## Why (the problem) + +1. **Truncation loses evidence.** A 40 KB JSON response or a multipart upload is + the thing a reviewer most wants, and it's exactly what the inline cap drops. +2. **Inline bodies bloat the chain.** Every byte of an inline preview is hashed + into the append-only chain and re-walked on every full verify. Bodies are + large, frequent (every request/response), and — unlike a marker — not the + operator's assertion; they don't belong in the chain, only their digest does. +3. **The pattern already exists.** Terminal stdout is NOT stored in the chain — + the `.cast` lives on disk and the event carries a reference + (`{stream:'cast', ref, off, len, truncated}`), sliced on demand by + `cast-slice.ts` (`src/core/api-server.ts` ~L409). The io_ref sidecar is the + same idea, generalized to HTTP (and future) bodies. + +## Core invariant (unchanged) + +**Bytes never enter the chain — only their `sha256`.** The v0.6.47 revert stands +(`ROADMAP.md`). A body's digest is chained; the bytes live in the sidecar. This +keeps `redlog-verify` and OpenTimestamps meaningful (they attest the digest, and +the sidecar bytes are checkable against it) while the chain stays small. + +## The sidecar + +- **Location:** `/io/` (peer of `screenshots/`, `terminal/`). +- **Append-only, content-addressed:** a body is written once to + `io/.bin` (dedup by digest — identical bodies stored once). No edits, + no deletes except retention pruning. +- **Ref shape on the event** (mirrors the io.stdout shape from v0.9.6): + ```jsonc + "io": { + "request": { "ref": "", "len": 40213, "sha256": "", "ct": "application/json", "truncated": false }, + "response": { "ref": "", "len": 128932, "sha256": "", "ct": "text/html", "truncated": false } + } + ``` + `ref` is the sidecar filename stem; `len` the full byte length; `truncated` + true only if capture itself hit a hard ceiling (see limits). The short inline + `*_preview` fields stay for at-a-glance display and offline bundles; the sidecar + holds the full body. + +## Capture path (who writes the sidecar) + +Two options; **(B) is recommended** — it keeps the digest authoritative and the +addon dumb. + +- **(A) Addon writes the file.** The mitmproxy addon writes `io/.bin` + directly and posts the ref. Rejected: the addon runs under a HOME that may not + be the project dir; it would need the project path, and a capture source + writing into the evidence store is a wider trust surface. +- **(B) Addon posts the full body; the server sidecars it.** *(recommended)* The + addon posts the full body (up to a generous `REDLOG_MAX_IO` ceiling, e.g. + 2 MB) on a new field; `POST /api/events` computes the `sha256`, writes + `io/.bin` if absent, replaces the body with the `io.*` ref before the + event is chained, and keeps the short `*_preview` inline. The server is already + the single DB-write chokepoint (pause gate, `noteDbError`), so this is where + the sidecar write belongs. Bodies above the post ceiling are marked + `truncated:true` and only the preview is kept. + +## Read path + +- **`io:read` IPC** (already named in ROADMAP): `io:read(ref, off?, len?) → + bytes`, validated to stay within `/io/` (same path-validation + discipline as screenshot reads). Range args mirror the cast slice API. +- **ScannerDetail** lazy-loads: shows the inline preview immediately, and when a + body is sidecar-backed and larger than the preview, a "load full body + (`len`)" affordance calls `io:read`. Binary/oversize bodies say so rather than + dumping raw bytes. +- Generalizes: the same viewer serves any event carrying `io.*` refs (browser + console payloads, future large captures). + +## Export / retention / verify + +- **Bundle export** (`redlog-cli export bundle`): copy the referenced + `io/.bin` files into the bundle's `io/`; the manifest already lists + screenshots + casts, add io refs. Sanitized exports (`redlog-cli sanitize`) + apply to sidecar bodies too — a sanitized replacement body is written and the + `system.sanitized` event records the digest swap (per `redaction-design.md` + Layer 4), so the chain still verifies. +- **Retention** (`src/core/retention.ts`): prune `io/` bodies older than the + keep window, emitting `system.io_pruned` (already reserved in ROADMAP) so the + gap is explainable — same arc as `.cast` pruning. The chain (digests only) is + untouched; a pruned body reads as "pruned by retention" not "missing". +- **`redlog-verify.py`**: when an event carries `io.*` refs, verify the sidecar + file's `sha256` matches the chained digest (bytes-on-disk match the attested + hash). A pruned/absent body is reported as pruned, not tampered. + +## Limits & config + +| Knob | Default | Meaning | +|---|---|---| +| `REDLOG_MAX_BODY` | 16 KB | inline preview cap (unchanged, for at-a-glance) | +| `REDLOG_MAX_IO` | 2 MB | max body posted to the sidecar; above → `truncated`, preview only | +| retention keep-days | project retention | when `io/` bodies are pruned | + +## Acceptance criteria + +- **A1** A response body larger than `REDLOG_MAX_BODY` but under `REDLOG_MAX_IO` + is fully retrievable via `io:read`; ScannerDetail shows the full body on + demand, and `len`/`truncated:false` are correct. +- **A2** The event row + chain contain only the `sha256` (+ short preview), never + the full bytes; DB size is ~unchanged vs. today for large bodies. +- **A3** Identical bodies across events are stored once (dedup by digest). +- **A4** `redlog-verify.py` confirms the sidecar file matches the chained digest; + a retention-pruned body verifies as *pruned*, not *tampered*. +- **A5** Bundle export includes referenced io bodies; `ots verify` on the bundle + still passes (chain unchanged). +- **A6** Pause semantics hold: while recording is paused, no body is sidecarred + (the gate is at `insertEvent`/`POST /api/events`, which already covers this). +- **A7** Path traversal is impossible — `io:read` refuses any ref outside + `/io/`. + +## Migration & compatibility + +- Purely additive: events without `io.*` refs (all historical, and any capture + under the post ceiling) behave exactly as today via the inline preview. +- No chain migration — old events keep their inline previews; new large bodies + gain refs. `redlog-verify` handles both. + +## Non-goals + +- Not a general blob store or attachment system — scoped to capture payloads + (HTTP bodies first). +- No at-rest encryption of the sidecar (tracked separately as a 1.x item, + `ROADMAP.md`); the threat model discloses on-disk exposure honestly today. +- No streaming of unbounded bodies — `REDLOG_MAX_IO` is a real ceiling; above it + the preview + `truncated` flag is the honest record. + +## Build order (each testable) + +1. Sidecar store module (`src/core/io-store.ts`): `putBody(projectDir, bytes) → + {ref, len, sha256}` (dedup), `readBody(projectDir, ref, off?, len?)`. Pure-ish + + fs; unit-testable with a temp dir. +2. `POST /api/events` (option B): sidecar the full-body field, stamp `io.*`, + drop bytes before chaining. Unit + the existing mitm e2e extended to assert a + >16 KB body round-trips. +3. `io:read` IPC + path validation. +4. ScannerDetail lazy full-body load. +5. Bundle export + retention prune (`system.io_pruned`) + `redlog-verify.py`. + +Cross-references: `redaction-design.md` (Layer 4 sanitize), `event-schema.md` +(io.stdout precedent), `src/core/cast-slice.ts` (the reference-not-bytes model), +`hooks/mitmproxy-addon.py` (`REDLOG_MAX_BODY`), commit b986441 (request-body +display + raised cap). diff --git a/docs/SPEC-SCOPE-AWARE-LIFECYCLE.md b/docs/SPEC-SCOPE-AWARE-LIFECYCLE.md new file mode 100644 index 00000000..f95b8055 --- /dev/null +++ b/docs/SPEC-SCOPE-AWARE-LIFECYCLE.md @@ -0,0 +1,221 @@ +# Spec — Scope-Aware Sanitize + Retention + Artifact Rotation + +> **Implementation status (2026-08-13).** +> - ✅ **Part C — artifact lifecycle (G1, G2, G4):** shipped. `config.io` gains +> `warmDays` + `maxBytes` (G1); `artifact-pin.ts` pin score (G4); +> `artifact-gc.ts` `planArtifactRotation` — age-or-size, refcount-gated, +> pin-ordered (G2); `io-store` warm compress (gzip, original-digest-preserving) +> + transparent read/verify; `retention.ts` `sweepIoLifecycle` wires it, with +> marker/loot `_causes` pinning; `redlog-verify.py` + bundle-export handle warm +> bodies. 42 unit tests (A4/A5/A6/A7). +> - ✅ **Part B — decision core + scope wiring (G3 partial):** `classifyTarget` +> (pure, side-effect-free scope verdict) and `planScopeSanitize` (Part B +> planner incl. io-sidecar coverage + `unknown` flagging, A1/A2) shipped and +> unit-tested; the classifier is wired into retention so scope-priority eviction +> is **live** (A5 fully). 8 more tests. +> - ✅ **Part B execution (G3 complete):** shipped. `scope-sanitize.ts` +> `runScopeSanitize` applies the plan — whole-body placeholder for out-of-scope +> inline fields (`sanitized_events`) **and** io sidecar bodies (new +> `sanitized_io` table), refcount-safe (a body cited by any kept in-scope event +> is never sanitized), appending one chained `system.sanitized` with +> `io_replacements`. `bundle-export` serves the redacted bodies under their +> original names; `redlog-verify.py` reads the swap and reports them +> *sanitized*, never *tampered* (A1/A6). `internal` vs `client-deliverable` +> export profiles wired through `exportBundle` + the `data:exportBundle` IPC +> (§9). `unknown` targets are never auto-sanitized unless the operator opts in +> (A2). Pure placeholder + DB round-trip tests added. +> +> **The full spec (Parts A/B/C) is now implemented.** The one deliberate scope +> boundary: the CLI/HTTP export path stays internal-profile; the client- +> deliverable profile is exposed through the app (IPC) export. + +Written 2026-08-13. Unifies three previously-separate storage concerns into one +mechanism: **the hash chain is a small, immutable WORM spine; every heavy capture +artifact is a refcounted, scope-prioritized, lifecycle-managed store.** It extends +`SPEC-IO-SIDECAR.md` (the prunable io sidecar) and `redaction-design.md` (layer-4 +export sanitize) with (a) a scope-aware verdict on both sanitize and retention, +(b) a three-stage artifact lifecycle (hot → warm/compressed → pruned) with +size *and* age triggers, and (c) a per-capture-type coverage matrix that states +honestly where scope does and does not apply. + +## Why (the problem) + +1. **Capture broad, sanitize/prune later** (DESIGN-PRINCIPLES §2) makes broad + capture safe *only if* the detect/sanitize/prune machinery is strong. Today + sanitize is manual per-event and retention is time-only per artifact type. +2. **Client deliverables must not leak out-of-scope / adjacent-client content**, + but the *fact* a target was touched (`scope_violation`) must survive. Sanitize + must be able to key on scope, not just on hand-picked fields. +3. **Disk pressure is real** for long engagements — but the heavy artifacts + (`.cast`, screenshots) are exactly the ones a host-based scope predicate + cannot classify. Retention needs a size ceiling and a compression tier, not + just an age cutoff, and rotation order needs a priority signal. + +## Core invariant (unchanged) + +**The chain is sacred and small.** Only `sha256` digests, previews, and event +metadata (who/when/which host/verdict) enter the hash chain; they are **never** +rotated, compressed away, or pruned. Everything below acts on *artifact bytes* +(`io/*.bin`, `*.cast`, screenshot `*.jpg`) — the prunable payload — and every +lifecycle action **appends** a chained `system.*` audit event, so a rotated or +sanitized store is itself tamper-evident (a missing artifact reads as *pruned*, +never *tampered*; a stripped bundle is detectable). This is the Schneier–Kelsey / +WORM split the prior art converges on (see Prior art). + +## Part A — Per-capture-type coverage matrix + +Scope is a **host/target predicate**. Not every `agent_type` carries a host, so +scope-aware handling is **not** uniform. Three tiers: + +| Tier | agent_types | Has target? | io body? | Scope-aware sanitize | Scope-aware retention | +|---|---|---|---|---|---| +| **1 — target-clean** | `http`, `http_navigation`, `scanner`, `dns`, `browser`, `pivot` | yes (`dest_host`/`url`/`route`) | yes (Tier-1 only) | ✅ verdict via `ScopeMonitor.checkTarget` | ✅ pin=in-scope, GC=out-of-scope first | +| **2 — target-partial** | `shell`/`terminal`, `file_transfer`, `cleanup`, `agent`, `loot`, `marker` | sometimes | no | ⚠️ only when target present; else `unknown` bucket | ⚠️ `unknown` → in-scope window | +| **3 — no host** | `screenshot`, `clipboard`, `process`, `.cast` recordings | no | no | ❌ scope N/A — time/size retention only | ❌ scope N/A — time/size retention only | + +**Consequence stated plainly:** scope-aware retention reclaims Tier-1 io bodies +only, which are the *small* artifacts after dedup + the 2 MB ceiling. The disk +hogs (`.cast`, screenshots) are Tier 3 and must be handled by Part C's +size/compression rotation, **not** by scope. Do not let the UI imply "scope-aware +retention on = disk handled." + +### The `unknown` fallback (Tier 2) — asymmetric by design + +Scope verdict is an *inference* (DESIGN-PRINCIPLES §3), so an absent/ambiguous +target must not silently drive a destructive default. The safe default differs +between the two actions because their failure modes differ: + +- **Sanitize** (removes content from a *deliverable copy* — recoverable): an + `unknown` event is **neither auto-stripped nor auto-passed**; it surfaces in the + dry-run preview flagged for operator decision (default unchecked). Failure = + over/under-redacting a reviewable copy. +- **Retention** (deletes *local bytes* — irreversible): an `unknown` event gets + the **in-scope (longest) window**. Never silently expire unclassified evidence. + Failure = permanent loss, so bias to keep. + +## Part B — Scope-aware sanitize + +Extends layer-4 export sanitize (`src/core/sanitize.ts`). Reuses the existing +`sanitize()` machinery (per-event, `dryRun`, chained `system.sanitized`, +tamper-detectable) — adds a **scope planner** in front of it. + +1. **Planner** walks candidate events; for each, resolves its target + (`target_id`/`dest_host`/`url`) and calls `ScopeMonitor.checkTarget`. + Verdict ∈ {`in_scope`, `out_of_scope`, `excluded`, `unknown`}. +2. **Plan** = every `out_of_scope`/`excluded` event's body + sensitive fields + proposed for sanitization; `unknown` flagged; `in_scope` untouched. This is a + `dryRun` result — no writes yet. +3. **Sidecar coverage (critical):** the plan must include the event's io_ref + sidecar body, not just inline event fields. A Tier-1 out-of-scope response + body lives in `io/.bin`; sanitize writes a sanitized replacement body and + the bundle serves that (per `SPEC-IO-SIDECAR.md` "Export/retention"). Missing + this leaks out-of-scope bytes through the side door. +4. **Operator confirm gate**, then `sanitize --confirm` runs, appending one + `system.sanitized` per event with `reason: "scope:"`. +5. **Export profiles** (§9 progressive disclosure): `internal` (full) vs + `client-deliverable` (scope-sanitized). Default deliverable profile runs the + scope planner; full content is the advanced opt-out. + +## Part C — Artifact rotation (the lifecycle) + +RedLog "log rotate" ≠ syslog overwrite. It is a three-stage lifecycle over the +artifact stores, with **age *and* size triggers** (whichever hits first — +logrotate's `daily` vs `size`). + +``` +HOT ──(age > warmDays OR store > sizeCap)──▶ WARM ──(age > pruneDays OR still > sizeCap)──▶ PRUNED +uncompressed, zstd in place, bytes deleted, +instant read read = decompress, sha256 + row kept, + verify = re-hash original system.*_pruned appended +``` + +- **Warm (compress) is pure win and must exist before prune.** io bodies and + `.cast` are text-shaped (JSON/HTML/timing) → ~5–10×. Compression is reversible: + keep the **original** `sha256` (not a compressed-hash) so `redlog-verify` + decompresses and re-hashes to confirm bytes-on-disk match the attested digest. +- **Prune** reuses `retention.ts`; extend it from age-only to age-or-size, and + add an `io/` keep-window (today's config has cast/screenshot/agentTranscript + windows but **no io window** — see gap G1). + +### Refcount-gated deletion (restic/borg lesson) + +`io/.bin` is content-addressed + deduped, so a body may be referenced by +many events. **A body is prunable only when *every* referencing event is past its +window.** Deletion is a mark-and-sweep GC over event→sha refs, not a per-event +`rm`. Getting this wrong either leaks disk (never delete) or deletes a body still +cited by live in-scope evidence. (borg's `prune`-doesn't-`compact` trap: dropping +the reference must actually reclaim the bytes, in one attributable step.) + +### Scope as pin (IPFS lesson) — where A, B, C merge + +Rotation **order** under size pressure is driven by a pin score: + +- **Pinned (evicted last):** `in_scope` bodies; anything referenced by a `loot` or + `marker` event; anything an operator explicitly pinned. +- **Unpinned (evicted first):** `out_of_scope` / `excluded` / `unknown`, unmarked. + +So when `io/` exceeds its cap, GC compresses then prunes out-of-scope/unmarked +bodies first and keeps in-scope evidence longest — the single mechanism that +answers both "how does broad capture not fill the disk" and "where does scope +attach": scope attaches to **rotation priority**, never to capture. + +## Prior art + +- **Schneier–Kelsey hash-chain audit logs / WORM** — retention acts on content, + never on the tamper-evident chain. ([finqub], [emergentmind]) +- **Teleport session recording** — audit events and heavy recordings split across + backends; recordings run an explicit S3 lifecycle, are **not** auto-deleted, + default 1-year retention; rotated keys are decrypt-only. ([teleport-recording], + [teleport-backends]) +- **restic vs borg** — generational retention (keep N daily/weekly/monthly), and + the reclaim gotcha: borg `prune` drops refs but needs `compact`; restic + `forget --prune` repacks in one step. Deletion must actually free space. + ([restic-borg]) +- **IPFS pin + GC** — content-addressed store GCs unpinned blocks; pinning + protects. Maps to scope/marker = pin. ([ipfs-gc]) +- **SIEM hot/warm/cold + WORM** — tier down (compress) before delete; ~85–90% + cost cut; PCI-DSS 10.7: 12-month retention, recent 3 months immediately + available. ([logsentinel], [scality-tiers]) + +## Acceptance criteria + +- **A1** Export `client-deliverable` profile: a Tier-1 out-of-scope response body + (event field *and* io sidecar) is sanitized; the `scope_violation` event and + the touched host remain in the bundle. `in_scope` bodies are untouched. +- **A2** `unknown`-target events are never auto-sanitized; they appear in the + dry-run preview flagged, default unchecked. +- **A3** Retention never expires an `unknown`-scope artifact before the in-scope + window; out-of-scope artifacts expire on the short window. +- **A4** Warm stage: a compressed io body is still retrievable via `io:read`, and + `redlog-verify.py` confirms the decompressed bytes match the chained (original) + `sha256`. +- **A5** Size trigger: with `io/` over its cap, GC compresses then prunes + unpinned (out-of-scope/unmarked) bodies first; a body referenced by any event + still inside its window is never deleted (refcount-gated). +- **A6** Every rotation/sanitize action appends a chained `system.*` event + (`io_pruned`/`cast_pruned`/`screenshot_pruned`/`sanitized`); a pruned artifact + verifies as *pruned*, a sanitized one as *sanitized*, never as *tampered*. +- **A7** Tier-3 artifacts (`.cast`, screenshot, `clipboard`, `process`) are + governed by time/size retention only; no scope verdict is computed or implied + for them in the UI. + +## Gaps to close (implementation order) + +- **G1** Wire an `io` keep-window + size cap into `config.ts` retention block + (today: cast/screenshot/agentTranscript only). Prerequisite for Part C. +- **G2** Extend `retention.ts` from age-only to age-or-size, add the warm + (compress) stage, and make deletion refcount-gated over event→sha refs. +- **G3** Add the scope planner + `unknown` bucket in front of `sanitize.ts`, with + sidecar-body coverage and export profiles. +- **G4** Compute the pin score (scope verdict + marker/loot reference) as the + rotation eviction key. + +## Non-goals + +- **Not** scope-gated *capture*. Capture stays broad (§2); scope only drives + sanitize, retention priority, and export — never a capture blind spot, which + would also disable `scope_violation` recording. +- **Not** rotating, compressing, or pruning the hash chain, event rows, previews, + or `sha256` digests. Only artifact bytes move through the lifecycle. +- **Not** scope-slicing a single `.cast` or screenshot by host (Tier 3 is + time/size only). Visual/stream artifacts are out of scope's reach by construction. diff --git a/docs/SPEC-TIMELINE-AXIS.md b/docs/SPEC-TIMELINE-AXIS.md new file mode 100644 index 00000000..9689fbe2 --- /dev/null +++ b/docs/SPEC-TIMELINE-AXIS.md @@ -0,0 +1,163 @@ +# Spec — Timeline Reconstruction Axis (Phase C) + +Written 2026-08-11. Executable spec for the flagship timeline refactor from +`DESIGN-PRINCIPLES.md` §8/§9: re-organise the timeline from a live source-type +firehose into a **reconstruction surface organised by target and phase**. This +is the highest-value and highest-risk item in the UX plan — it touches the +4,100-line `Timeline.tsx` lane model — so it is specced before a line is wired. + +Foundation already shipped (Phase B, commit `d8494c2`): `phaseSegments`, +`phaseInference`, `targetGrouping`. This spec is how they reach the screen. + +## Goal + +A reviewer reconstructing an engagement asks *"what happened to this target, in +what order?"* and *"reconstruct this phase (recon → exploit → pivot → exfil)."* +The current 18 source-type lanes answer *"which streams are active"* — a live +question that now belongs to the HUD (§8). Phase C makes the timeline answer the +reconstruction questions. + +## Non-goals (unchanged from the principles) + +- No opinionated ATT&CK/correlation output. Phase inference stays a **suggestion** + (§3) — dashed, promotable, never authoritative. +- Timeline stays the **event map**; it does not grow inline I/O content (that is + the transcript/exchange reader, §8 / Q10). +- No new capture. This is a presentation refactor over existing events + the + Phase B seams. + +## The axis model (recommended design) + +Two orthogonal controls, not one three-way switch: + +1. **Lane axis** — the swim-lane rows. Toggles between: + - `source` *(current, default at ship)* — the 18 source-type lanes, unchanged. + - `target` *(new)* — one lane per target from `groupByTarget(events)`, plus the + explicit **untargeted** lane always last (§12). Each event's dot keeps its + source-type colour/shape, so source is still legible as an encoding, just + not as the row axis. +2. **Phase ribbon** — a thin horizontal band across the top of the time axis, + independent of the lane axis, rendered from `phaseSegments(...)`. Operator + phase-markers draw **solid** bands; `inferPhaseSuggestions(...)` draws + **dashed/ghost** bands with a "suggest" affordance that promotes to an operator + marker on click (§11). Toggleable; off by default until the operator has + phases. + +> **Why a ribbon, not a third lane mode:** phase is inherently *temporal* +> (segments over time), so it maps naturally to time-bands, not rows. Making it a +> ribbon lets target-lanes and phase coexist ("show me, per target, which phase +> each action fell in") instead of forcing a choice. **Open question O1** records +> the alternative. + +### Data flow + +``` +events ──┬─ groupByTarget() → target lanes (axis='target') + ├─ toLane() [existing] → source lanes (axis='source') + ├─ phaseMarkersFromEvents → phaseSegments() → solid phase bands + └─ inferPhaseSuggestions() → dashed phase bands +event dot colour/shape ← source-type (unchanged encoding, both axes) +``` + +## Safe refactor strategy — incremental, behind a toggle + +The rule: **never a big-bang replacement of the lane model.** Order: + +1. **Extract T5 seams first** (make the current behaviour testable before + changing it): + - `lib/timelineCluster.ts` — the 14px same-lane cluster bucketing (currently + inline). Pure `clusterEvents(events, pxPerMs, laneOf)`. + - `lib/laneVisibility.ts` — populated / visible / hidden / solo resolution + (currently inline; pairs with the T3 modes seam already shipped). + Each ships with unit tests and is imported back into Timeline.tsx with **zero + behaviour change** (renderer-smoke + timeline-keys stay green). This buys the + first real interaction-test coverage on the file before we touch its axis. +2. **Introduce `laneAxis` state** (`'source' | 'target'`), default `'source'`. + Add a `lib/timelineAxis.ts` seam: `lanesForAxis(axis, events, ...)` returning + the ordered lane descriptors — for `'source'` it returns today's lanes + (delegating to the existing logic), for `'target'` it wraps `groupByTarget`. + The renderer maps events to rows through this one function regardless of axis. + Ship with `'source'` as default so nothing changes until the operator opts in. +3. **Wire the target axis** behind a header control (part of the T4 "View + options" menu). Verify empty/untargeted handling, cluster + visibility still + work per-lane, and the T3 modes row reflects target-lane solo/hide. +4. **Add the phase ribbon** (solid from markers, dashed from inference, promote + action). Independent toggle. +5. **Fold TargetView in** — once the target axis is solid, TargetView becomes the + timeline with `laneAxis='target'`; deprecate the separate view or make it a + deep-link into that axis. (Sequenced last; not required for the axis to ship.) + +Each step is independently shippable and independently revertible. The default +stays `source` until `target` is proven, so no operator loses the current view on +upgrade. + +## Seams to build (all pure, TDD, renderer lib) + +| Seam | Purpose | Ships in step | +|---|---|---| +| `timelineCluster.ts` | cluster bucketing, testable | 1 | +| `laneVisibility.ts` | populated/visible/hidden/solo | 1 | +| `timelineAxis.ts` | `lanesForAxis(axis, events, …)` → ordered lanes | 2 | +| `phaseRibbon.ts` | merge `phaseSegments` (solid) + `inferPhaseSuggestions` (dashed) into render-ready bands, with promote targets | 4 | + +Phase B seams (`phaseSegments`, `phaseInference`, `targetGrouping`) are consumed, +not rebuilt. + +## Acceptance criteria + +- **A1 — no-regression default:** with `laneAxis='source'` and the phase ribbon + off, the timeline is byte-for-byte the current behaviour. `renderer-smoke` and + `timeline-keys` stay green; the T5 extractions change nothing observable. +- **A2 — target axis:** `laneAxis='target'` renders one lane per target ordered by + first activity, untargeted last; every event appears in exactly one lane; dots + keep source-type encoding. +- **A3 — untargeted honesty:** events with no target land in the explicit + untargeted lane, never dropped, never guessed into a target (§12). +- **A4 — phase ribbon:** operator phase-markers render as solid bands spanning + their segment; inferred suggestions render dashed and are visually distinct; + clicking a suggestion's promote affordance creates an operator marker (an + authoritative, attributable, on-chain fact — §3/§11). +- **A5 — cross-feature integrity:** cluster popovers, lane solo/hide (T3 row), + the `/` filter, zoom/pan, and the T2 wheel behaviour all work under both axes. +- **A6 — I/O boundary held:** the timeline still surfaces I/O only as a glyph; + drill-down still opens the transcript (§8 / Q10). No inline I/O content added. + +## Regression guards (hard gates each step) + +- `npx vitest run` on: the new seam tests, `renderer-smoke`, `timeline-keys`, + `timeline-modes`, `timeline-wheel`, plus (steps 1) the extracted-seam tests. +- `npm run build` green. +- i18n en/zh parity for any new axis/ribbon copy. +- Manual smoke on a transcript-heavy project before folding TargetView in (step 5). + +## Risks & mitigations + +| Risk | Mitigation | +|---|---| +| Big-bang breaks the fragile 4,100-line file | Toggle-behind + `source` default; each step revertible; T5 seams give test coverage *before* the axis change | +| Target-lane count explodes on a wide engagement | Lane virtualization (T6) — sequence it alongside/after; until then cap + "N more targets" like the cluster cap | +| Phase inference reads as authoritative | Enforced by design: dashed rendering + promote-to-marker; inference never writes to the chain (§3) | +| Concurrent edits to Timeline.tsx (has happened) | Single-owner sequential passes; check `git status`/mtime before each | + +## Open questions — RESOLVED 2026-08-11 + +- **O1 — phase representation → RIBBON overlay.** Phase renders as a horizontal + time-band at the top of the time axis, independent of the lane axis, so target + lanes and phase coexist. (Not a third `laneAxis='phase'` mode.) +- **O2 — default axis at ship → keep `source`.** `target` is opt-in; nothing + changes on upgrade until the operator switches. Revisit the default after + dogfooding. +- **O3 — TargetView → REMOVE.** The `target` lane axis fully subsumes TargetView; + step 5 deletes the separate view rather than keeping it as a deep-link. (More + decisive than the original recommendation — the axis is the one place targets + are reviewed.) + +## Sequencing summary + +Step 1 (T5 seams, no behaviour change) → Step 2 (`timelineAxis` seam, source +default) → Step 3 (wire target axis behind View options) → Step 4 (phase ribbon + +promote) → Step 5 (**remove** TargetView, per O3). Steps 1–2 shipped (commits +7c98579, dfceb47); O1–O3 resolved, so 3–5 are unblocked. + +Cross-references: `DESIGN-PRINCIPLES.md` §3/§8/§9/§11/§12 · `UX-TIMELINE-2026-08.md` +(T-series) · `UX-BACKLOG-TICKETS.md` (T4/T5/T6) · Phase B commit `d8494c2`. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 00000000..995ba8a4 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,687 @@ +# RedLog Test Matrix + +Every configurable option, every value that behaves differently, and where each +one is proven. If you change a default, add a field, or touch the alert path, +this is the file that says what the change is allowed to break. + +``` +npm test # the whole vitest suite (unit + renderer) +npx vitest run test/ # one file +npx vitest # watch mode +npm run e2e # Playwright, needs a built app +npm run typecheck # renderer types +``` + +**After an e2e run, `npm test` will fail to start.** `pree2e` points +`node_modules/.bin/node-gyp` at `@electron/node-gyp`, so `pretest`'s +`npm rebuild better-sqlite3` then builds the native module against Electron +headers and vitest cannot load it (`Could not locate the bindings file`, even +though the file is right there — it is the ABI that is wrong). Recover with: + +``` +rm -rf node_modules/better-sqlite3/build +(cd node_modules/better-sqlite3 && ../../node_modules/node-gyp/bin/node-gyp.js rebuild --release) +``` + +## How to read a row + +| Column | Meaning | +|---|---| +| **Value** | the literal a config file can hold, including the junk values | +| **Behaviour** | what RedLog does with it — stated as an observable outcome | +| **Proof** | vitest file that asserts it, `e2e` for Playwright, `manual` for §5, `—` for an open gap | + +Proof names drop the `test/` prefix and the `.test.ts(x)` suffix. + +--- + +# Part 1 — The alert path + +The one sequence that has to be right: an address or a target is observed, a +verdict is computed, and the verdict reaches the operator's eye. RedLog never +blocks (`ALERT-ROLES.md` Part D), so a verdict that is wrong or invisible is the +whole defence failing — there is nothing downstream to catch it. + +``` +egress IP ──► classifyIP ──► settling/stale ──► ipBadge ──► HUD + card + status bar +command ──► classifyDistance ──► alertFloor ──► violation event ──► ScopeStatus +``` + +## 1.1 IP verdict matrix — which list wins + +Two lists, each independently set or unset, and an address that may hit either. +Nine reachable cells. + +Five verdicts, because the two lists answer different kinds of question. Three +are **observations** and one is an **inference**, and §3 says an inference may +never wear a fact's colour: + +| Verdict | Authority | Means | +|---|---|---| +| `safe` | fact | on an address you declared safe | +| `exposed` | fact | on an address you declared to be *you* | +| `off_profile` | fact | you declared an expectation and this is **outside** it | +| `presumed_safe` | **inference** | "not obviously you" — a blacklist is set and this missed it | +| `unknown` | — | nothing declared; there is no question to answer | + +| # | whitelist | blacklist | address hits | Verdict | Why | +|---|---|---|---|---|---| +| A-1 | — | — | — | `unknown` | nothing declared, nothing to judge against | +| A-2 | — | set | blacklist | `exposed` | your own IP is showing | +| A-3 | — | set | neither | `presumed_safe` | an inference, and rendered as one | +| A-4 | set | — | whitelist | `safe` | on a declared exit | +| A-5 | set | — | neither | `off_profile` | an observed deviation from a declared expectation | +| A-6 | set | set | both | `exposed` | config conflict resolves to the alarming side | +| A-7 | set | set | blacklist | `exposed` | identity leak is never masked | +| A-8 | set | set | whitelist | `safe` | | +| A-9 | set | set | neither | `off_profile` | **the VPN-dropped-onto-café-NAT case** | + +Proof: `ip-monitor` (table-driven, one `it` per row). + +Two fixes are pinned here against re-introduction: + +- **G-A1** — A-9 answered `safe`, because the blacklist-only shortcut ran even + after a configured whitelist had already missed. `ip-monitor` keeps both the + truth-table row and the scenario-shaped assertion. +- **G-A2** — A-3 answered `safe` (an inference in a fact's solid green) and A-5 + answered `unknown`, which understated an observed deviation into the same + amber bucket as "nothing is configured at all". Both now have their own + verdict. + +## 1.2 Range matching — `network.whitelist` / `network.blacklist` + +| Value | Behaviour | Proof | +|---|---|---| +| `10.8.0.0/24` | matches `10.8.0.0` through `10.8.0.255`, including network + broadcast | `ip-monitor-options` | +| `10.8.0.0/24` vs `10.8.1.0` / `10.7.255.255` | one address either side is outside | `ip-monitor-options` | +| `172.16.0.0/12` | matches up to `172.31.255.255`, not `172.32.0.1` | `ip-monitor-options` | +| `203.0.113.42/32` | single host | `ip-monitor-options` | +| `0.0.0.0/0` | matches everything | `ip-monitor-options` | +| `203.0.113.42` (no mask) | exact equality, never a prefix — `203.0.113.4` does not match | `ip-monitor-options` | +| `2001:db8::1/64` | IPv6 compares the literal prefix address only; `::2` does not match | `ip-monitor-options` | +| IPv4 CIDR vs IPv6 address | family mismatch never matches | `ip-monitor-options` | +| multiple entries | a hit on any one entry is a hit | `ip-monitor-options` | +| `[]` (default) | list is not configured → see A-1/A-3/A-5 | `ip-monitor`, `config-options` | + +The blacklist is evaluated first and wins outright; a whitelist that is set but +missed answers `off_profile` — never `safe`, and never the same `unknown` that +"nothing configured" gets. + +## 1.2.0 `network.lanProfile` — which segment you expect to be ON + +The same classifier as `whitelist`, pointed at the INTERNAL address, with no +blacklist — so only three of the nine cells are reachable. + +| Value | Internal address | Verdict | Proof | +|---|---|---|---| +| unset | anything | `unknown` — nothing was declared, so nothing is claimed | `ip-monitor` | +| set | inside a listed CIDR | `safe` | `ip-monitor` | +| set | outside every listed CIDR | `off_profile` | `ip-monitor` | + +`internalIP` was collected and displayed but never judged, so a laptop that +silently reassociated to a guest SSID read exactly like one still on the client +VLAN — the external IP can be perfectly fine while you are on the wrong network. +The LAN verdict never goes `stale`: it is a local read of the network +interfaces, so a failed external lookup does not expire it (and the failed poll +now re-reads it rather than discarding it with the rejection). + +## 1.2.1 `network.staleAfter` — a verdict expires + +| Value | Behaviour | Proof | +|---|---|---| +| `2` (default) | after 2 consecutive failed reads the verdict decays to `unknown` and `stale` is set | `ip-monitor` | +| `N` | same, at N | `ip-monitor` | +| `0`, negative | rejected, default kept | `ip-monitor` | +| a later success | verdict re-computes, `stale` clears | `ip-monitor` | + +A dropped VPN and a dead IP provider look identical from outside the process, so +neither may keep rendering the last good answer at full confidence. `stale` beats +`settling` on the badge: no reading at all is a stronger statement than an +unconfirmed one (`ip-badge`). + +## 1.3 `network.confirmations` — flap protection + +A new address is held until it has been read this many times in a row. The +displayed address, and therefore the verdict, does not change during the hold. + +| Value | Behaviour | Proof | +|---|---|---| +| `1` | promote on sight, no flap protection | `ip-monitor-options` | +| `3` (default) | two reads hold the old address, the third promotes | `ip-monitor`, `ip-monitor-options` | +| `5` | four reads hold, the fifth promotes | `ip-monitor-options` | +| `0`, negative | rejected — falls back to 3, **not** to "promote instantly" | `ip-monitor-options` | +| first ever reading | taken as-is; there is nothing to flap against | `ip-monitor` | +| candidate changes each poll (CGNAT) | nothing is ever promoted; `settling` stays true | `ip-monitor` | +| old address returns mid-hold | the half-confirmed candidate is dropped | `ip-monitor` | + +`settling: true` is the "displayed value is the last stable read" flag. It is set +for the whole hold and cleared on promotion (`ip-monitor-options`). + +## 1.4 `network.ipMode` and `network.providers` + +| Option | Value | Behaviour | Proof | +|---|---|---|---| +| `ipMode` | `auto` (default) | DNS first, HTTP only if DNS fails/blocked | `ip-monitor-dns` | +| `ipMode` | `dns` | direct query to OpenDNS/Google resolvers, no HTTP at all | `ip-monitor-dns` | +| `ipMode` | `http` | HTTP echo only, DNS never queried | `ip-monitor-options` | +| `providers` | `[]` (default) | the three built-in echo services are used | `ip-monitor-options` | +| `providers` | custom list | tried in order; first success wins | `ip-monitor`, `ip-monitor-options` | +| `providers` | first entry throws | falls through to the next | `ip-monitor-options` | +| `providers` | first entry answers non-200 | treated as a failure, moves on | `ip-monitor-options` | +| `providers` | response `{ip}` or `{origin}` | both shapes are accepted | `ip-monitor-options` | +| `providers` | every entry fails | `error: 'All IP providers failed'`, **last known address is kept** | `ip-monitor`, `ip-monitor-options` | +| `providers` | a later poll succeeds | error clears | `ip-monitor-options` | + +## 1.5 `network.checkInterval` + +| Value | Behaviour | Proof | +|---|---|---| +| unset (monitor default) | polls every 10 s | `ip-monitor-options` | +| `60` (config default) | polls every 60 s | `ip-monitor-options`, `config-options` | +| `0` | ignored — a zero-second poll would be a busy loop | `ip-monitor-options` | +| `stop()` | clears the timer, idempotent | `ip-monitor-options` | +| slow poll still in flight | the next tick is skipped, not queued | `ip-monitor-options` | + +## 1.6 Scope distance ladder — `classifyDistance` + +How far a target sits from declared intent (`ALERT-ROLES.md` Part B). The tier +decides whether an alert fires at all, so every rung needs its own row. + +| Tier | Distance | Meaning | Alerts? | +|---|---|---|---| +| D0 | `in_scope` | hits a scope entry | no | +| D1 | `excluded` | hits an exclude entry — a **fact** | **always**, even with warnings off | +| D2 | `adjacent_subnet` | same container as a *single-IP* scope entry — **inferred** | only when `warnOnViolation` | +| D2 | `adjacent_domain` | same registrable domain as a scope entry — **inferred** | only when `warnOnViolation` | +| D3 | `unrelated` | no relationship to anything declared | never; **counted** via `getUnrelatedCount()` | + +D1 outranks D0: an explicit exclusion inside a broad CIDR still fires. + +| Case | Scope | Target | Distance | Proof | +|---|---|---|---|---| +| no scope set | `[]` | anything | `in_scope` | `scope-monitor-behaviour` | +| single-IP entry expands into its container | `192.168.1.10` | `192.168.1.55` | `adjacent_subnet` | `scope-monitor-behaviour` | +| a written CIDR is **not** widened | `10.0.0.0/24` | `192.168.50.1` | `unrelated` | `scope-monitor-behaviour` | +| wildcard expands to the registrable domain | `*.staging.example.com` | `prod.example.com` | `adjacent_domain` | `scope-monitor-behaviour` | +| unrelated domain | `*.staging.example.com` | `google.com` | `unrelated` | `scope-monitor-behaviour` | +| `proximityBits: 16` | `192.168.1.10` | `192.168.2.55` | `adjacent_subnet` | `scope-monitor-behaviour` | +| `proximityBits: 24` (default) | `192.168.1.10` | `192.168.2.55` | `unrelated` | `scope-monitor-behaviour` | +| nonsensical `proximityBits` | any | any | falls back to 24 rather than opening up | `scope-monitor-behaviour` | + +The IP/domain asymmetry is deliberate: a bare IP carries no boundary, so +deriving one fills a gap; a CIDR *states* a boundary, and widening it would +invent authorisation the operator never gave. A domain always expands to its +registrable domain because that is the ownership boundary the authorisation is +about — hitting `prod` while scoped to `staging` is exactly what D2 exists for. + +## 1.6.1 Scope verdicts — `classifyTarget` + +The coarser four-value verdict shared by sanitize and retention (D2 and D3 both +collapse to `out_of_scope` here). + +| Target | Scope | Verdict | Proof | +|---|---|---|---| +| `null` / `''` / `undefined` | any | `unknown` (caller picks the safe default) | `scope-monitor-behaviour` | +| anything | `targets: []` | `in_scope` — no scope set means everything is in scope | `scope-monitor-behaviour` | +| `10.0.0.9` | `10.0.0.0/24` | `in_scope` | `scope-monitor-behaviour` | +| `10.0.1.9` | `10.0.0.0/24` | `out_of_scope` | `scope-monitor-behaviour` | +| `api.app.example.com` | `*.app.example.com` | `in_scope` | `scope-monitor-behaviour` | +| `app.example.com` | `*.app.example.com` | `in_scope` — the wildcard covers its anchor host | `scope-monitor`, `scope-monitor-behaviour` | +| `vpn.example.com` | `*.app.example.com` | `out_of_scope` (same root, outside the wildcard) | `scope-monitor-behaviour` | +| `google.com` | `*.app.example.com` | `out_of_scope` | `scope-monitor-behaviour` | +| `10.0.0.1` | in `targets`, also in `excludeTargets` | `excluded` — exclusion beats the enclosing CIDR | `scope-monitor-behaviour` | +| `dc01.app.example.com` | in the wildcard, also excluded | `excluded` | `scope-monitor-behaviour` | + +CIDR and wildcard mechanics themselves (`/8` `/16` `/24` `/32`, `*.` prefix, root +extraction) are table-tested in `scope-monitor`. + +## 1.7 `scope.alertFloor` — how much the operator is told + +The lowest rung that still raises an alert. It replaced the `warnOnViolation` +boolean (G-C3), which could only silence D2 and gave an operator who wanted to +see *everything* no setting at all. + +| Distance | `excluded_only` | `adjacent` (default) | `all` | Proof | +|---|---|---|---|---| +| D0 in scope | no violation | no violation | no violation | `scope-monitor-behaviour` | +| D1 excluded | **violation** | **violation** | **violation** | `scope-monitor-behaviour` | +| D2 adjacent subnet | silent, `inScope: false` | **violation** | **violation** | `scope-monitor-behaviour` | +| D2 adjacent domain | silent, `inScope: false` | **violation** | **violation** | `scope-monitor-behaviour` | +| D3 unrelated | counted only | counted only | **violation** | `scope-monitor-behaviour` | + +D1 fires at every floor: "keep off X" is not a preference call. D3 is counted at +every floor, because silent is not the same as not looking — the count is what +proves the difference (`getUnrelatedCount()`). + +Bookkeeping: violations accumulate rather than dedupe, each carries +target/command/timestamp, `getViolations()` returns a copy, and the full command +is kept in memory while the chained event slices it to 200 chars +(`scope-monitor-behaviour`). A violation event is `system` / +`subtype: scope_violation` carrying `reason` (`excluded_target` \| +`adjacent_subnet` \| `adjacent_domain` \| `unrelated`) and `authority` +(`fact` \| `inferred`), so downstream can tell an observation from an inference +rather than seeing one undifferentiated red (`scope-violation-event`). +`scope_violation` is also the default deconfliction subtype (`deconfliction`). + +Whatever the tier, the command itself still lands in the timeline — only the +*alert* is suppressed, never the record. + +`configure()` semantics: omitting `alertFloor` on a later call keeps the current +value; replacing `targets` re-derives the containers used for D2; a re-configured +`proximityBits` takes effect on the next check (`scope-monitor-behaviour`). + +## 1.7.1 One severity scale for both alarms + +The self alarm (IP) and the target alarm (scope) grew separate colour +vocabularies: four tones on one side, an on/off light on the other, so a D1 hit +on a forbidden host and a D2 proximity inference rendered identically. G-C1 puts +both on one scale — `ok` < `notice` < `unknown` < `warn` < `critical` +(`alert-severity`). + +| Input | Severity | +|---|---| +| `exposed` / D1 `excluded_target` | `critical` — the thing you must not do, observed, non-silenceable | +| `off_profile` / D2 adjacent | `warn` | +| D3 `unrelated` (only at `alertFloor: all`) | `notice` — a recorded departure, not an alarm | +| `unknown` | `unknown` | +| `safe` / `presumed_safe` | `ok` | + +`worstSeverity()` folds several inputs into the single dot the status bar shows. +Authority is carried **orthogonally** to severity: `dotShape()` renders an +inferred event as a hollow/dashed mark and an observed one as solid, so a +judgement never looks like a measurement at any severity (`dot-shape`, +`authority`, `authority-stamp`). + +## 1.8 Alert display — the verdict has to be seen + +The same verdict has to be legible on three surfaces, and the two qualified +verdicts must not borrow a plain one's confidence. Proof: `alert-display` +(HUD + dashboard) and `alert-surfaces` (status bar); the decision itself is +`ip-badge`. + +| Verdict | Severity | HUD label | Dashboard card | Status bar | +|---|---|---|---|---| +| `safe` | `ok` | `SAFE` | green dot, `Safe IP` | green dot, `SAFE` + address | +| `presumed_safe` | `ok` | `PRESUMED` | `Presumed safe` + "nothing to confirm it against" hint | `SAFE`, qualified | +| `off_profile` | `warn` | `OFF-PROFILE` | `Off-profile IP` + "not where you declared" hint | `OFF-PROFILE` | +| `exposed` | `critical` | `EXPOSED` | red pulsing dot + "check your VPN" hint | red dot, `EXPOSED` | +| `unknown` | `unknown` | `IP?` | yellow dot + "configure the lists" hint | amber dot, `IP?` | +| any + `stale` | forced `unknown` | verdict expired | `No current reading` + "a dropped VPN looks exactly like this" | `NO READ` | + +The HUD frame turns red on `exposed` (`#d75f63`) and stays cyan (`#3fc7d6`) +otherwise; `flashOnExposed` controls only the flash, never the colour. + +Also asserted: the expanded pane spells the verdict out in words +(`Exposed IP — Not Protected`, `Presumed safe — inferred, not verified`) rather +than relying on colour alone; every qualified verdict carries the instruction +that would upgrade it to a verified one; a provider error is shown instead of +pretending the reading is fresh; a missing reading renders `—` rather than a +stale address, and the status bar omits the address entirely. + +**Scope alerts** (`alert-surfaces`): the status bar shows `SCOPE OK` or +`SCOPE `; the Scope & Evidence view distinguishes `NOT SET` (with the hint +that fixes it) from `ACTIVE` + "all commands within scope", lists each violation +with its target and command, caps the recent list at 10 rows without losing the +count, and only offers the Export button once there is something to export. + +**Recording vs capture health** (`alert-surfaces`): `REC` pulsing red only means +recording *and* a healthy capture. A `partial` verdict turns it amber and still +pulsing; `dark` turns it amber and stops the pulse, because "REC" on its own +would be a lie when nothing is feeding events. + +**Live updates** (`alert-surfaces`): the open HUD subscribes to all five overlay +settings (`showMark`, `flashExposed`, `scale`, `emphasizeIp`, `passThrough`), and +each one takes effect without a restart — including the `0`-opacity rejection, +which is enforced on the live message as well as on the config read. + +--- + +# Part 2 — Config option matrix + +Defaults below are asserted one-per-`it` in `config-options`, so a changed +default fails a named test rather than a distant integration. + +## 2.1 `engagement` / `operator` + +| Option | Default | Behaviour | Proof | +|---|---|---|---| +| `engagement.id` | `default` | stamped on every event | `config-options` | +| `engagement.name` | `Default Engagement` | display only | `config-options` | +| `operator.id` | `operator-1` | attribution; no operator = no capture (see 2.5, 2.9) | `config-options` | +| `operator.name` | `Operator` | display only | `config-options` | + +## 2.2 `network` + +| Option | Default | Values | Behaviour | Proof | +|---|---|---|---|---| +| `whitelist` | `[]` | IPs / CIDRs | §1.1–1.2 | `ip-monitor*` | +| `blacklist` | `[]` | IPs / CIDRs | §1.1–1.2, wins over whitelist | `ip-monitor*` | +| `checkInterval` | `60` | seconds | §1.5; UI coerces junk to 60 | `ip-monitor-options` | +| `providers` | `[]` | URLs | §1.4 | `ip-monitor-options` | +| `confirmations` | `3` | ≥1 | §1.3; UI clamps to ≥1 | `ip-monitor-options` | +| `staleAfter` | `2` | ≥1 | §1.2.1 — consecutive failed reads before the verdict expires to `unknown` + `stale` | `ip-monitor` | +| `ipMode` | `auto` | `dns` \| `http` \| `auto` | §1.4 | `ip-monitor-dns` | +| `showWifiName` | `false` | bool | off drops the SSID from the link before it reaches any surface, keeping the link **type** (the UI renders a generic "Wi-Fi"); on shows it. The macOS toggle also asks for Location Services, since the OS redacts the SSID without it. Turning it off applies immediately rather than at the next 20 s poll | `wifi-name-policy` | +| `vpnAdapters` | 12 built-ins, all enabled | `{name, pattern, enabled}` | patterns are user regexes matched case-insensitively against interface names | `vpn-adapters` | + +`vpnAdapters` detail (`vpn-adapters`): a disabled adapter matches nothing; an +empty list means no interface is ever VPN; several adapters can match and the +result is sorted; an interface with no external address is skipped; **a malformed +regex is dropped instead of throwing**, so one bad pattern cannot take the OPSEC +poller down; re-configuring replaces the pattern set. The shipped list is +verified to recognise `wg0`, `tun0`, `tap0`, `tailscale0`, `nordlynx`, `proton0`, +`utun4`, `ipsec0`, `ppp0`, and to leave `en0` / `eth0` / `wlan0` alone. + +## 2.3 `scope` + +| Option | Default | Behaviour | Proof | +|---|---|---|---| +| `alertFloor` | `adjacent` | §1.7 — `excluded_only` \| `adjacent` \| `all`; the lowest rung that still alerts | `scope-monitor-behaviour` | +| `targets` | `[]` | §1.6; empty = everything in scope | `scope-monitor-behaviour` | +| `excludeTargets` | `[]` | D1: always warns when hit | `scope-monitor-behaviour` | +| `proximityBits` | `24` | container width derived for a **single-IP** scope entry; entries already written as CIDRs are never widened; values outside 1–32 or non-integers fall back to 24. Settings ▸ Scope renders it under the alert-floor control and hides it at `excluded_only` (with D2 silenced there is nothing to widen); the UI clamps to 1–32 and coerces junk to 24 | `scope-monitor-behaviour`, `config-options`, `settings-interaction` | +| `publicSuffixes` | `[]` | extra multi-label suffixes on top of the built-in table, so an engagement on a suffix RedLog has not heard of is not blocked on a release. **Additive only** — the built-ins cannot be removed | `public-suffix` | +| `scopeFile` | `null` | external scope document, loaded on open | `config-options` | + +`scopeFile` parsing (`config-options`): plain text is one target per line with +`#` comments and blank lines dropped; a file with no extension is read as text; +a JSON array keeps only its string entries; malformed JSON, an unrecognised JSON +shape, and a missing file all yield `[]` rather than throwing — an unreadable +scope must never take the project open down with it. + +Burp/ZAP hold a scope host as a **regex**, so `burpHostToTarget()` decodes the +shapes those tools write into RedLog target syntax: + +| Burp host | Decoded | Meaning | +|---|---|---| +| `^example\.com$` | `example.com` | exact host | +| `\Qexample.com\E` | `example.com` | literal-quoted | +| `.*\.example\.com$` | `*.example.com` | "and subdomains" | +| `.*\Qcorp.example.com\E` | `*.corp.example.com` | same, literal-quoted | + +`\Q…\E` runs are expanded wherever they appear and however many times; a +hand-written pattern that is still regex-shaped afterwards (`(dev|prod)\.…`) is +handed back **untouched** rather than dropped or half-converted. It matches +nothing, but it stays visible in the scope list — a scope target that vanishes +is the failure with no symptom, since the operator sees a scope load +successfully and never learns which hosts are missing from it. + +## 2.4 `screenshot` + +| Option | Default | Values | Behaviour | Proof | +|---|---|---|---|---| +| `quality` | `85` | 1–100 | passed to the JPEG encoder verbatim; `0` is ignored and 85 is used | `screenshot-options` | +| `intervalSec` | `0` | seconds, `0` = off | `0` and negatives schedule nothing; `30` schedules a 30 s loop; `12.9` floors to 12 s; setting it back to `0` cancels the running loop; re-configuring replaces the timer instead of stacking one | `screenshot-options` | + +Capture gating (`screenshot-options`): a periodic capture of an unchanged screen +is skipped (exact-bytes then perceptual dHash), a changed screen is captured +again, and a **manual** capture always lands — including while recording is +paused, where periodic capture is suppressed. No operator id means nothing is +scheduled and nothing is captured. Files land in `/screenshots/` and the +event records trigger, size, dimensions and sha256. + +## 2.5 `overlay` — HUD appearance + +| Option | Default | Values | Behaviour | Proof | +|---|---|---|---|---| +| `showMarkButton` | `true` | bool | `false` hides both mark buttons in the expanded pane, keeping the keep-open toggle | `alert-display` | +| `showInDock` | `true` | bool | macOS only: keeps a Dock icon once the HUD is shown. Stays manual — `app.dock.hide()` takes effect asynchronously via the activation policy and its result is **not observable** through `app.dock.isVisible()` in an automated Electron run (polled 5 s, still reports visible), which is also why main re-runs `applyDock()` on a timer and on overlay show | manual §5.4 | +| `flashOnExposed` | `true` | bool | `false` keeps the red frame but stops the flashing; never flashes while `safe` | `alert-display` | +| `scale` | `1.0` | 0.85 / 1.0 / 1.25 / 1.5 in the UI | scales type and padding; **clamped to 0.75–2.0**; `0` and negatives are ignored (treated as unset) | `alert-display` | +| `emphasizeExternalIp` | `false` | bool | multiplies **only** the external IP by a further 1.4×, compounding with `scale` | `alert-display` | +| `passThrough` | `false` | bool | dims HUD chrome and lets mouse events through | `alert-display` | +| `passThroughOpacity` | `0.4` | 0.1–0.9 in the UI | applied to chrome while `passThrough` is on; `0` is ignored (an invisible HUD is worse than none); **the external IP is never dimmed** | `alert-display` | + +Measured values for `scale` (external IP font size, base 12 px): +`0.85 → 10.2`, `1.0 → 12`, `1.25 → 15`, `1.5 → 18`, `99 → 24` (clamped), +`0.01 → 9` (clamped); with `emphasizeExternalIp`: `1.0 → 16.8`, `1.5 → 25.2`. + +## 2.6 `terminal` / `screenshots` / `io` — retention + +| Option | Default | Behaviour | Proof | +|---|---|---|---| +| `terminal.maxCastBytes` | `52428800` (50 MB) | a session's `.cast` is truncated at the cap with an inline marker; values ≤ 0 are ignored | `cast-slice` (slicing), manual §5.5 (truncation) | +| `terminal.castKeepDays` | `0` | `0` = keep forever; `N` deletes `.cast` files older than N days on project open, one `system.cast_pruned` audit event per deletion | `retention` | +| `screenshots.keepDays` | `0` | same shape, `system.screenshot_pruned` | `retention` | +| `io.keepDays` | `0` | prunes `io/.bin` sidecars, `system.io_pruned`; a body still referenced by a fresh event is kept (refcount-gated) | `retention`, `artifact-gc` | +| `io.warmDays` | `0` | `0` = never compress; `N` gzips bodies older than N days in place, keeping the ORIGINAL sha256 so verification still passes | `artifact-gc`, `io-store` | +| `io.maxBytes` | `0` | `0` = no cap; over the cap, unpinned bodies are evicted first (out_of_scope before unknown), and a pinned body is never evicted even if that leaves the store over cap | `artifact-gc`, `retention` | + +Every prune writes an audit event, so a pruned body verifies as *pruned*, not as +tampered (`retention`, `io-store`). + +## 2.7 `clipboard` + +| Option | Default | Behaviour | Proof | +|---|---|---|---| +| `enabled` | `false` | off captures nothing at all; flipping it off stops the poll; on start the current clipboard is seeded so pre-session content is never captured | `clipboard-options` | +| `pollMs` | `1500` | honoured as given, **clamped up to 500 ms** | `clipboard-options` | +| `storePreview` | `false` | off stores hash + length + line count and `preview: null`; on stores a preview capped at 120 chars with high-entropy secrets masked to `•` | `clipboard-options` | + +Invariants (`clipboard-options`): raw clipboard text never appears in the event +whatever the setting; identical consecutive reads dedupe to one event; an empty +clipboard produces nothing; paused recording suspends capture and resuming +restores it; detected credential *types* are recorded without copying the +credential; a throwing loot detector does not lose the event. + +## 2.8 `browser` + +| Option | Default | Behaviour | Proof | +|---|---|---|---| +| `binary` | `''` | auto-detect per platform (incl. per-user Windows installs) | `browser-launcher` | +| `proxy` | `http://127.0.0.1:8080` | adds `--proxy-server` **and** `--proxy-bypass-list=<-loopback>`; `''` omits both | `browser-launcher` | +| `cdpPort` | `9222` | `--remote-debugging-port`; `0` omits it | `browser-launcher` | +| `isolateProfile` | `true` | `--user-data-dir` + `--no-first-run`; `false` leaves the daily profile alone | `browser-launcher` | +| `ignoreCertErrors` | `true` | `--ignore-certificate-errors` so the intercepting CA is accepted; `false` restores validation | `browser-launcher` | +| `startUrl` | `''` | appended **last** so it is the target, not a flag value; omitted when empty | `browser-launcher` | +| `extraArgs` | `[]` | appended before the start URL; empty strings dropped | `browser-launcher` | + +A fully stripped-down config produces no flags at all (`browser-launcher`). + +## 2.9 `redaction` + +| Option | Default | Behaviour | Proof | +|---|---|---|---| +| `entropyThreshold` | `4.5` | Shannon entropy above which a token is flagged | `redaction` | +| `minLength` | `20` | shorter tokens are never entropy-flagged | `redaction` | +| `denylist` | `[]` | substring match, or `/pattern/` for regex; a malformed regex is ignored | `redaction` | +| `allowlist` | `[]` | suppresses an entropy match | `redaction` | + +Plugin-contributed denylist entries merge in and unregister cleanly +(`redaction`); masking preserves span length (`redaction`, `secret-redaction`). + +## 2.10 `deconfliction` + +| Option | Default | Behaviour | Proof | +|---|---|---|---| +| `enabled` | `false` | disabled sends nothing | `deconfliction` | +| `url` | `''` | empty sends nothing even when enabled | `deconfliction` | +| `secret` | `''` | HMAC over the exact bytes sent | `deconfliction`, `signing` | +| `events` | `marker, system, credential_use, c2_checkin` | only these agent types are forwarded | `deconfliction` | +| `subtypes` | `scope_violation` | forwarded even when the agent type is not listed | `deconfliction` | +| `includeData` | `false` | **the PII gate** — the event body is omitted unless this is on | `deconfliction` | +| `authorityFloor` | `inferred` | lowest §3 authority tier to forward: `inferred` sends both tiers labelled; `fact` holds D2 proximity inferences back so the blue team only hears about observed rule matches (G-C2) | `deconfliction` | + +Events batch into a single POST and buffered events flush on shutdown rather +than being dropped (`deconfliction`). + +## 2.11 `cloudShare` / `marketplace` + +| Option | Default | Behaviour | Proof | +|---|---|---|---| +| `cloudShare.endpoint` | `''` | empty falls back to the local `file://` stub uploader | `cloud-share`, `cloud-share-uploader` | +| `cloudShare.authToken` | `''` | sent as `Authorization: Bearer …` on the upload init | `cloud-share-uploader` | +| `cloudShare.maxBundleBytes` | unset → 100 MB | an oversized bundle is rejected and the zip cleaned up | `cloud-share` | +| `marketplace.defaultRegistryUrl` | bundled example registry | **dormant**: `MARKETPLACE_ENABLED = false` shelves the only panel that reads it, so the setting currently has no UI consumer at all. The shelved state itself is asserted, so un-shelving trips a test rather than quietly reviving an untested option | `settings-interaction` | + +Bundle building is gated on the reviewed-by-operator flag and produces +zip + `manifest.json` (`cloud-share`); registry installs enforce revocation, +signatures, and tarball hash/metadata agreement (`marketplace`, `publisher-trust`). + +## 2.12 `fileWatcher` / `processMonitor` / `agentTailer` + +| Option | Default | Behaviour | Proof | +|---|---|---|---| +| `fileWatcher.enabled` | `false` | starts only with enabled + non-empty `watchPaths` + an engagement id; flipping off stops the watcher | `file-watcher` | +| `fileWatcher.watchPaths` | `[]` | empty is a no-op even when enabled | `file-watcher` | +| `fileWatcher.ignorePatterns` | `[]` | added on top of the built-in ignores | `file-watcher` | +| `processMonitor.enabled` | `false` | off by default; Windows emits a one-shot advisory | `process-monitor` | +| `processMonitor.pollMs` | `500` | poll cadence; floored at 200 ms, and at **2000 ms on Windows** where a cold PowerShell spawn is 800–1500 ms and a 500 ms cadence would stack calls | `process-monitor-cadence` | +| `processMonitor.ignoreCommands` | `[]` | leading-token match, on top of the built-ins | `process-monitor` | +| `agentTailer.enabled` | `true` | on by default; a `.redlog-app-root` marker opts a repo out | `agent-transcript-tailer` | +| `agentTailer.emitThinking` | `false` | thinking blocks are excluded unless turned on | `agent-transcript-tailer` | + +--- + +# Part 3 — Config file handling + +| Case | Behaviour | Proof | +|---|---|---| +| no `config.yaml` | full defaults | `config`, `config-options` | +| corrupt / empty YAML | falls back to defaults, never throws | `config-options` | +| partial file | merges over defaults; siblings keep their defaults | `config`, `config-options` | +| arrays | **replace**, never concatenate | `config-options` | +| explicit `[]` | wins over a non-empty default | `config-options` | +| explicit `false` / `0` | kept, not treated as "unset" | `config-options` | +| unknown key | carried through, not dropped | `config-options` | +| save → load | round-trips every block | `config`, `config-options` | +| `saveConfig` into a missing directory | creates it | `config-options` | + +Legacy migration (`config-options`, `config`): + +| Old | New | Note | +|---|---|---| +| `network.vpnIPs` | `network.whitelist` | legacy key is deleted so it cannot re-migrate | +| `network.safeIPs` | `network.whitelist` | beats `vpnIPs` when both are present | +| `network.dailyIPs` | `network.blacklist` | | +| `network.exposedIPs` | `network.blacklist` | | +| `scope.enforcement: warn` | `warnOnViolation: true` | | +| `scope.enforcement: log` | `warnOnViolation: false` | the only value that meant quiet (and it did not even log) | +| `scope.enforcement: block` | `warnOnViolation: true` | `block` was the strictest value the old field offered; answering it with silence would give less protection than was asked for, on a config nobody revisits | +| `scope.enforcement: ` | `warnOnViolation: true` | unrecognised values fail loud, not quiet | +| `scope.warnOnViolation: false` | `alertFloor: 'excluded_only'` | the boolean could only silence D2 (G-C3) | +| `scope.warnOnViolation: true` | `alertFloor: 'adjacent'` | | + +An explicit `warnOnViolation` is never overwritten by a stale `enforcement` key. + +--- + +# Part 4 — Renderer surfaces + +| Surface | Covered | Proof | +|---|---|---| +| every view mounts with one event of each agent type | crash-on-open regressions | `renderer-smoke` | +| HUD verdict colour / label / flash / scale / emphasis / pass-through / mark buttons | §1.8, §2.5 | `alert-display` | +| dashboard IP card: three verdicts, both addresses, hints, provider error | §1.8 | `alert-display` | +| status bar: IP verdict, scope count, recording × capture-health dot, loot | §1.8 | `alert-surfaces` | +| scope violations list, empty/not-set states, 10-row cap, chain length, export button | §1.8 | `alert-surfaces` | +| HUD live-update subscriptions (Settings → open overlay) | §1.8 | `alert-surfaces` | +| Settings controls: each toggle / number field writes the right config key, with its UI-layer coercion | §2.x | `settings-interaction` | +| process-monitor poll cadence + platform floors | §2.12 | `process-monitor-cadence` | +| one severity scale across both alarms; `worstSeverity` folding | §1.7.1 | `alert-severity` | +| inferred-vs-observed rendering (dot shape), orthogonal to severity | §1.7.1 | `dot-shape` | +| `authority` resolution + the stamp `insertEvent` writes | §1.7.1 | `authority`, `authority-stamp` | +| `scope_violation` payload: `reason` + `authority` per rung | §1.7 | `scope-violation-event` | +| registrable-domain table incl. operator-added suffixes | §2.3 | `public-suffix` | +| settings search index covers every group | option discoverability | `settings-search` | +| timeline lanes, axis, clustering, modes, wheel, geometry | timeline behaviour | `timeline-*` | +| capture onboarding + readiness | empty/partial states | `capture-onboarding-render`, `capture-readiness`, `empty-state` | +| keyboard shortcuts, split pane, lane visibility/colours | UI plumbing | `shortcuts`, `split-pane`, `lane-*` | + +Playwright (`npm run e2e`) covers the HUD overlay window, recording pause, +project flow, command IO, marketplace flow, cloud-share flow, transcript view +and timeline presentation against a built app. + +`ip-alert.spec.ts` is the alert path through real windows: a verdict pushed on +`ip:status` turns the HUD frame red and flashes it, reaches the main window's +status bar at the same time, clears back to cyan on `safe`, stays amber and +distinct on `unknown`, and stops flashing the moment `flashOnExposed` is saved +false — no restart. The verdict is pushed rather than provoked by a real egress +lookup: the classifier has exhaustive unit coverage, and an e2e run must not +depend on the machine's network position. + +**Not covered end to end:** a scope violation cannot be provoked from the +renderer bridge (no "emit a shell event" IPC), so §1.6–1.7 stop at the unit +level plus the manual walk-through in §5.3. + +--- + +# Part 5 — Manual QA + +What no unit test can reach. Run before a release, or after touching the alert +path. + +### 5.1 Trigger an EXPOSED alert end to end + +1. Settings ▸ Network: put your real egress IP (`curl ifconfig.me`) in the + **blacklist**, leave the whitelist empty. Set `confirmations: 1` to skip the + hold. +2. Disconnect the VPN. +3. Within `checkInterval` seconds the HUD frame turns red, flashes, and reads + `EXPOSED`; the dashboard card shows a red pulsing dot and the "check your + VPN" hint. +4. Reconnect the VPN → back to `SAFE` (or `unknown` if no whitelist is set). +5. Set `overlay.flashOnExposed: false` and repeat: still red, no flashing. + +### 5.2 Trigger the UNKNOWN (dropped-VPN) case + +Whitelist your VPN exit CIDR, blacklist your home IP, then connect through a +third network (phone hotspot). The verdict must be `IP?` / amber — **never** +green. This is the A-9 case from §1.1. + +### 5.3 Trigger a scope violation + +1. Settings ▸ Scope: `targets: *.app.example.com, 192.168.1.10`, warnings on. +2. Run `curl vpn.example.com` in a hooked shell → D2 violation badge, red row in + Scope & Evidence, `system/scope_violation` event with + `reason: adjacent_domain`, `authority: inferred`. +3. Run `nmap 192.168.1.55` → D2 again (`reason: adjacent_subnet`), because the + single-IP entry expands to `/24`. +4. Run `curl google.com` and `nmap 8.8.8.8` → no alert (D3, by design); the + commands are still in the timeline. +5. Add `dc01.app.example.com` to `excludeTargets`, set `alertFloor: + excluded_only`, hit it → still alerts (`reason: excluded_target`, + `authority: fact`). Hit `vpn.example.com` and `192.168.1.55` again → silent. +6. Set `alertFloor: all` and run `nmap 8.8.8.8` → now it alerts too, as + `notice`-severity `unrelated`, visibly below the D1/D2 rungs. + +### 5.4 `overlay.showInDock` (macOS) + +Show the HUD with the option on → Dock icon stays. Turn it off → Dock icon +disappears while the HUD keeps working. **This step cannot be automated**: the +Dock change goes through the macOS activation policy asynchronously and +`app.dock.isVisible()` keeps reporting the old value in an automated run, so an +e2e assertion here would be a false green rather than coverage. + +### 5.5 `terminal.maxCastBytes` + +Set a small cap (e.g. 4096), run `yes | head -100000` in the built-in terminal, +and confirm the `.cast` ends with the `[redlog: cast truncated at N bytes]` +marker and the event records `castTruncated`. + +### 5.6 Wi-Fi SSID (`network.showWifiName`, macOS) + +Toggle it on, accept the Location Services prompt, and confirm the HUD shows the +real SSID instead of a generic `Wi-Fi`. Toggle it back off — the HUD must drop +to `Wi-Fi` straight away, not at the next poll. The SSID names the building you +are sitting in and the HUD is the surface most likely to be in frame on a +screenshot, so "off" has to mean off everywhere, immediately. + +--- + +# Part 6 — Known gaps + +| ID | Gap | Impact | +|---|---|---| +| **G-UI1** | `overlay.showInDock` is the last manual-only option. `app.dock.hide()` changes the macOS activation policy asynchronously and the result does not surface through `app.dock.isVisible()` in an automated run, so the assertion would be a false green. | One setting, verified by §5.4. The other three originally filed here turned out not to need e2e at all: `authToken` was already covered (`cloud-share-uploader`), `pollMs` is decided synchronously (`process-monitor-cadence`), and `defaultRegistryUrl` is dormant behind a shelved panel. | + +Adding a config option? Add its default to the table in `config-options`, its +behaviour to the relevant Part 2 section, and — if it changes what the operator +sees — an assertion in `alert-display`. + +The first of those is **enforced, not requested**: `config-options` walks the +real default config and fails on any option the table does not name (and on any +table entry whose option has been removed). It found `network.staleAfter` and +`scope.publicSuffixes` the first time it ran — both had reached Part 2 of this +document but not the assertions, which is exactly the drift a sentence in a doc +cannot prevent. diff --git a/docs/TIMELINE-ELEMENTS.md b/docs/TIMELINE-ELEMENTS.md new file mode 100644 index 00000000..718b4053 --- /dev/null +++ b/docs/TIMELINE-ELEMENTS.md @@ -0,0 +1,120 @@ +# Timeline Elements — the reconstruction surface, decomposed + +Written 2026-08-13. Applies `DECOMPOSITION-METHOD.md` to the timeline as a +**structural lens**, complementing (not replacing) the design docs that own the +timeline: `SPEC-TIMELINE-AXIS.md` (the axis refactor), `UX-TIMELINE-2026-08.md` +(the UX diagnosis + T1–T6), and `DESIGN-PRINCIPLES.md` §8 (timeline = event map, +transcript = I/O reader) / §9 (necessity). Those are the source of truth for +*what to build*; this doc adds the closed **element taxonomy** — so the timeline's +complexity has a structural name (channel/mode overloading) and every element +declares whether it shows a fact or a suggestion. + +## The principle everything derives from + +The timeline is a **visual encoding surface**: it maps data dimensions to visual +variables and exposes interactions over them. Its felt complexity (UX-TIMELINE's +"invisible, overloaded, context-dependent") is **channel/mode overloading** — one +visual channel carrying two data dimensions, or one gesture bound to two actions. +The decomposition's payoff: **one channel = one data dimension; one mode = one +action; every element declares its §3 authority.** When that holds, the surface is +legible by construction. + +> **Implementation status varies by branch.** This is a *structural* taxonomy, not +> a shipped-state guarantee. Some elements are live on `feat/ux-design-and-tickets` +> (lane axis, phase ribbon, wheel/modes/keys resolvers), some only have their +> underlying data on `main` with the visual still proposed (instance-ordinal +> badges, shell bracketed-cast preview). Rows below tag **[shipped]** / **[data +> only]** / **[proposed]** where sessions have confirmed; confirm against git +> before relying on any as done. + +## Two families (mechanism axis) + +``` +Does it map a DATA dimension → a visual variable? → ENCODING CHANNEL +Does it let the operator NAVIGATE or MUTATE? → INTERACTION MODE +``` + +## The authority axis (§3, applies to every element) + +Every element renders either a **fact** (operator marker, primary capture) or an +**inferred suggestion** (ambiguous target, inferred phase) — and the two must be +**visually distinct** (solid vs dashed/ghost) with one-click promotion. This is +already the phase-ribbon design (solid markers vs dashed inference, +`SPEC-TIMELINE-AXIS.md` §11); the taxonomy makes it a rule for *all* elements. + +## Encoding channels (closed set) + +Visual variables are a bounded set (Bertin: position, colour, shape, size/density, +plus the temporal band, and composites of these — e.g. an instance ordinal = +colour + text-value) — so this table is provably exhaustive over what the surface +*can* encode. + +| Channel | Data dimension | Authority | Seam / source | +|---|---|---|---| +| **Position-X** | time (the one fixed anchor) | fact (wall-clock, but see clock-anomaly) | time scale | +| **Lane (Y-group)** | organizing axis — `source` **or** `target`/phase (§8) | fact | `lanesForAxis(axis, events, …)` (implemented: header ⊞ toggle) | +| **Colour** | source-type (kept as encoding even when lane = target) | fact | event `agent_type` | +| **Glyph / shape** | event kind | fact | event registry | +| **Instance ordinal** | which concurrent instance — `instanceOf(event)`: shell = `terminal_id` (ext-shell fallback `pid`), agent = `session_id`; HTTP/scanner `flow_id` **excluded** (per-request, not a persistent instance) | fact (recorded id) | **[data only]** `session_id`/`terminal_id` exist in event data on `main`; the **[proposed]** visual = coloured ordinal badge (dot corner) + detail chip (`term #2`) + cluster-popover `#N`, shown only when a lane has >1 concurrent instance | +| **Density / cluster** | event count in a 14px time bucket | fact | `timelineCluster.ts` | +| **Phase ribbon (band)** | phase segments over time | **fact (solid marker) + suggestion (dashed inference)** | `phaseRibbon.ts` | + +**The §8 fix restated in channel terms:** originally **lane and colour were both +bound to source-type** — redundant, and the lane channel maxed out at 18 groups. +§8 re-binds **lane ← target/phase**, freeing colour to keep encoding source without +the 18-lane overload. One channel, one dimension. + +## Interaction modes (closed set) + +Interaction reduces to a bounded set — navigate, filter, select, mutate — so this +too is exhaustive. + +Seams below are the **implemented** pure resolvers on the PR #8 branch (per the +timeline-axis session), not spec placeholders. + +| Mode | Action | State visibility | Implemented seam | +|---|---|---|---| +| **Navigate** | pan / zoom / scroll the time axis | — (the overloaded one) | `lib/timelineWheel.ts` (`wheelMode` decision matrix) + `lib/timelineKeys.ts` (keyboard resolver) | +| **Filter** | show/hide/solo lanes; source-type demoted to a filter (§8) | T3 active-modes row | `lib/timelineModes.ts` (`activeModes` — which non-default modes are hiding events) | +| **Select / inline-preview → drill-down** | three-part (§8 refined): **axis = glyph** presence **[shipped]** (v0.9.2 `eventTitle` role prefixes `❯ ◂ ⚙ ↩ 💭`); **select = inline short I/O preview** — agent tier-2 **[shipped]** (v0.9.2 `AgentTurnDetail`, `CollapsibleStream startOpen`, *text* body), shell bracketed-cast stdout preview **[proposed]** (`CommandEndDetail` shows stdout/stderr today but not the bracketed-cast visual); **full narrative = transcript** **[proposed]** ("Open transcript" button, like today's "Open sidecar") | selection highlight | detail inline preview + transcript view | +| **Mutate / promote** | inferred → authoritative (dashed phase → solid marker, §3) | the "suggest" affordance | phase-ribbon promote action | + +## Completeness + +- **Channels:** the surface can encode only via position, colour, shape, density, + a temporal band, or a composite of these (instance ordinal = colour+text-value) + — a closed set. Every current visual maps to one row; nothing falls outside. +- **Modes:** an operator can only navigate, filter, select, or mutate — a closed + set. Every current gesture maps to one row. +- **Authority:** every element is fact or suggestion (§3) — no third state. + +So the timeline is fully described by **7 channels × 4 modes × the fact/suggestion +axis**. A new timeline feature is one of these, or it is over-encoding (see gaps). + +## Gaps — the UX-TIMELINE problems, restated structurally + +| # | UX-TIMELINE problem | Structural name | Fix | +|---|---|---|---| +| G1 | "same gesture does different things by invisible state" (wheel) | **one mode bound to two actions** (pan/zoom/scroll on the same wheel, switched by hidden state — the `timelineWheel.ts` `wheelMode` matrix) | T2 — one gesture, one action; make the binding explicit | +| G2 | "every affordance invisible until `?`" | **modes have no state channel** — interaction state isn't encoded on the surface | T1 persistent legend + T3 active-modes row (give modes a visible channel) | +| G3 | "18-colour problem" | **lane + colour both bound to source-type** (redundant, lane overloaded) | §8 axis re-bind: lane ← target/phase, colour ← source | +| G4 | inference vs fact must not blur | **authority axis not uniform across elements** — only phase does solid/dashed today | extend solid-vs-dashed to every inferred element (ambiguous target, inferred tags) per §3 | +| G5 | timeline trying to be the I/O reader | **Select mode scope** — where does I/O content live | §8 refined (three-part): axis = glyph; select = *inline short* preview in the detail; *full* narrative stays in the transcript/exchange view — the map never becomes the full reader | + +## Why this lens pays + +- It gives the timeline's complexity a **structural diagnosis** (overloaded + channel/mode) instead of a list of symptoms — each T-ticket becomes "un-overload + channel X / mode Y." +- It makes "should this go on the timeline?" answerable: which channel or mode is + it? If none, it belongs in a paired surface (transcript/HUD/search, §8), not the + map. +- It carries §3 onto every visual element by contract, not by memory. + +## Cross-references + +- Owns the axis design: `SPEC-TIMELINE-AXIS.md` +- Owns the UX diagnosis + tickets: `UX-TIMELINE-2026-08.md`, `UX-BACKLOG-TICKETS.md` (T1–T6) +- Surface split (map vs reader vs HUD vs search): `DESIGN-PRINCIPLES.md` §8, §9 +- Fact vs inferred suggestion: `DESIGN-PRINCIPLES.md` §3 +- The method + its variants: `DECOMPOSITION-METHOD.md` diff --git a/docs/UX-AUDIT-2026-08-13.md b/docs/UX-AUDIT-2026-08-13.md new file mode 100644 index 00000000..5be3e88f --- /dev/null +++ b/docs/UX-AUDIT-2026-08-13.md @@ -0,0 +1,195 @@ +# UX 全面評估紀錄 — 2026-08-13 + +以 **v0.11.7** 為基準,對整個 renderer 逐頁(16 個視圖)做操作動線評估。切入問題: +「從每個功能與互動往下操作,是否能簡單直覺地完成、並取得需要的資訊?畫面佔比是否可調?」 + +本文是 [`UX-AUDIT-2026-08.md`](UX-AUDIT-2026-08.md)(2026-08-10 複雜度稽核)與 [`UX-TIMELINE-2026-08.md`](UX-TIMELINE-2026-08.md) 的續作,聚焦 +**佔比/縮放(resize)**、**發現性(discoverability)** 與 **跨視圖一致性** 三條軸。 +所有結論皆對照當前已提交程式碼親自核實;行號以核實當下為準。 + +配套的兩份設計文件: +- [`DESIGN-SYSTEM.md`](DESIGN-SYSTEM.md) — 統一色彩/字型/字級/icon/元件/版面框架 +- [`DESIGN-TIMELINE-DISCOVERABILITY.md`](DESIGN-TIMELINE-DISCOVERABILITY.md) — 滾輪/軸切換發現性細部重設計 + +--- + +## 0. 總體判斷 + +RedLog 的視覺語言統一、資訊密度控制良好、鍵盤化程度高(`⌘1–9` 導覽、`⌘K` palette、`⌘.` +暫停錄製、HUD 角落吸附)。對「證據鏈稽核」這個核心任務,資訊呈現是稱職的。 + +兩個系統性弱點: + +1. **畫面佔比幾乎全部寫死、不可調整**(§1)。 +2. **重要操作藏在看不見的手勢/修飾鍵裡**,發現性差,靠原生 `title` tooltip 補救(§3、§4)。 + +Dashboard 與 HUD Overlay 是全 app 設計最成熟的兩處,可作為其他視圖的標竿。 + +--- + +## 1. 佔比/縮放(resize)— 最明顯的結構性缺口 + +| 機制 | 現況 | 位置 | +|---|---|---| +| 主視窗大小 | ✅ 可調、會記住(預設 1100×700,最小 800×500) | `src/main/windows.ts:10-15` | +| 全域 UI 縮放 | ✅ 有(Chromium `zoom` 0.9–1.5,Settings ▸ Advanced) | `src/renderer/src/main.tsx:11` | +| Sidebar 寬度 | ❌ 固定 140px,不可收合/拉寬 | `Sidebar.tsx:120` | +| 主內容區 vs Sidebar 佔比 | ❌ 純 `flex-1`,無可拖曳分割線 | `App.tsx:248` | +| Timeline 詳情面板高度 | ✅ **全 app 唯一**可拖曳縮放(上下),雙擊重設,存 localStorage | `Timeline.tsx:3571` | +| Timeline lane 標籤欄/minimap/事件列表 | ❌ 固定寬高(`LABEL_W`、`h-9`、18/22vh 寫死) | `Timeline.tsx:3530` | +| FindingsView 左欄 | ❌ 硬編 `w-80` | `FindingsView.tsx:98` | +| EventMarker 彈窗 | ❌ 硬編 `w-[420px]` | `EventMarker.tsx:72` | +| Terminal 分割窗(並排) | ❌ 只有分頁,不能左右/上下分割 | `TerminalView.tsx` | +| 跨視圖並排(如 Timeline + Terminal) | ❌ 一次只能顯示一個 view | `App.tsx:251-280` | +| 響應式佈局 | ❌ 全 renderer 僅 **1 處** `md:` 斷點;`grid-cols-3/4` 全寫死 | — | + +**影響**:長 URL/note/loot preview 只能靠 `truncate`,操作者無法把某欄拉寬讀全值; +800px 最小寬度下 `grid-cols-3`(Dashboard 統計卡、Screenshots 網格)會擠壓。 + +**建議(依投報比)**: +1. **Sidebar 可收合**(icon-only ↔ 展開)+記住狀態 — 成本最低、對小螢幕效益最大。 +2. **共用 ``**:Transcript 問答塊、FindingsView 左右欄、Loot 清單皆適用;佔比存 localStorage + (Timeline 詳情面板已證明此模式在本專案可行)。 +3. **Terminal 分割窗**:滿足「一邊跑指令一邊看時間軸」的高頻紅隊需求。 +4. 關鍵格線補 `sm:`/`md:` 斷點。 + +框架化細節見 [`DESIGN-SYSTEM.md` §6 版面與縮放框架](DESIGN-SYSTEM.md)。 + +--- + +## 2. 逐視圖操作動線與合理性 + +### 2.1 完整視圖清單(16) +ProjectPicker、Dashboard、Terminal、Timeline、Transcript、Screenshots、Scope、Loot、 +Marks(QuickMarks)、Settings、Search、Sidebar、StatusBar、Overlay HUD、EventMarker、IPStatusCard。 +("Targets" 側邊項不是獨立頁,是深連結到 Timeline 的 target 軸。) + +### 2.2 標竿頁 + +**Dashboard** — IA 全 app 最佳。擷取健康卡用「例外報告」邏輯(只顯示壞掉的來源)、 +dark/partial/healthy 三態、onboarding checklist + 單一主 CTA,回答「時間軸空的、然後呢」。 +`App.tsx:373-593`。 + +**HUD Overlay** — glance 操作的典範:可拖曳、角落鍵盤吸附、`⚡` 快速標記(不搶焦點、inline `✓` 確認)、 +pass-through 半透明但外部 IP 保持不透明、exposed 時整框紅色告警、內容自動 `autosize`。`OverlayApp.tsx`。 + +### 2.3 稱職但有小缺點 + +- **ProjectPicker**:最近專案一鍵開、進階設定收摺疊區不淹沒新手。**刪除專案無二次確認**。 +- **Terminal**:多分頁、`⌘T/⌘W/⌘⇧[]`、字級 `⌘±`、per-pane 搜尋、關閉有確認、分頁顯示 cwd+exit code。 + 唯一大缺點是**無分割窗**(§1)。 +- **Transcript**:事件折成問答直讀、filter+kind chips+Export MD+跳 Timeline、標示「為何無輸出」四態。設計好,但不能調欄寬。 +- **Settings**:五分頁(Capture/Scope/Evidence/OPSEC/Advanced)**均正常運作**,Advanced 用 5 個 + `Collapsible` 做漸進揭露,有設定內搜尋、auto-save。真實缺點見 §2.5。 + +### 2.4 Timeline — 痛點最集中 + +資料與泳道設計本身沒問題,問題在**互動發現性**。完整細節與重設計見 +[`DESIGN-TIMELINE-DISCOVERABILITY.md`](DESIGN-TIMELINE-DISCOVERABILITY.md)。摘要: + +- **滾輪行為取決於看不見的狀態**:無修飾鍵滾動時,泳道「有無溢出」決定它是平移時間軸還是垂直捲動; + 溢出與否隨視窗高度/泳道數動態改變(`lib/timelineWheel.ts:41`)。補救提示只在滾動當下閃 2.5 秒(事後通知)。 +- **切到 target 軸會靜默讓整排 lane 篩選 chip 消失**(`Timeline.tsx:2924` 的 `laneAxis === 'source' &&` 條件)。 +- **無 fit-to-window**:`100% ↺` 在 <2000px 寬視窗按了仍看不到全部(`baseTrackW` 下限 2000)。 +- 雙擊只在 4px 把手有意義;Alt-click solo 泳道只寫在 tooltip;`↑↓` 走訪事件有「須先開詳情面板」的隱形前提。 +- `⌘K` 開本地 palette、`⌘/` 卻跳全域 Search,分歧只寫在 `App.tsx:136` 註解。 + +### 2.5 Settings 的真實可改善點(已更正) +> 註:先前一份自動盤點誤讀舊版檔案,宣稱「三分頁空白/typecheck 紅/死碼」。經 `npx tsc --noEmit -p tsconfig.web.json` +> 實測乾淨、render 條件為新五分頁、i18n key 齊全,**該說法不成立**,不予採納。 + +- **auto-save + 手動「儲存」按鈕 + 「會自動儲存」提示三者並存**,訊號矛盾(`Settings.tsx:738-755`); + 且儲存失敗被 `.catch(() => {})` 靜默吞掉(`:160`)。 +- **搜尋涵蓋率低**:35 個設定群組有 16 個 `labelKeys: []`(約 46%),只能搜群組標題; + 搜「proxy/port/token/regex」等實際存在的欄位是零結果。 +- **危險操作確認不一致**:只有「刪除操作員」有 `confirmDialog`(`:1789`); + **token rotate、revoke、匯入 profile(整包覆蓋設定,`:465-467`,無 diff 預覽)都沒有確認**。 + +--- + +## 3. 跨視圖共同痛點 + +| # | 痛點 | 具體位置 | +|---|---|---| +| C1 | **資訊只能看不能取用** — 🔧 **Loot 已修**(2026-08-13):Loot 改 SplitPane 清單|詳情,詳情面板顯示完整值 + 複製鈕(Transcript 詳情亦有 input/output 複製)。**待做**:IP/mark URL 點擊複製 | `LootPanel.tsx` `LootDetail`、`TranscriptView.tsx` `TranscriptDetail` | +| C2 | **跳轉 Timeline 靜默失效**:`onOpenInTimeline` 為 optional,父層沒傳時按鈕 disabled 但仍佔位 | `SearchPanel.tsx:122`、`ScopeStatus.tsx:95`、`LootPanel.tsx:169` | +| C3 | **空狀態不一致**:Transcript/Loot/Findings 有 CTA;**ScopeStatus 未設 scope 只有圖示+文字、無 CTA**(死路);Timeline「被篩到 0 筆」與「真的沒事件」畫面相同,前者無「清除篩選?」 | `ScopeStatus.tsx:69`、`Timeline.tsx:2442` | +| C4 | **篩選入口出現閾值不一**:Findings 搜尋框要 marks≥5、Loot chips 要型別≥2、Search chips 要型別>1 — 資料少時找不到本以為存在的篩選 | `FindingsView.tsx:136`、`LootPanel.tsx:121` | +| C5 | **幾乎全靠原生 `title` tooltip** 傳達關鍵手勢(~1 秒延遲、無法設樣式、觸控/鍵盤拿不到) | 全域 | +| C6 | **Modal 無 focus trap**,Tab 會跑出對話框 | `EventMarker.tsx:31` | +| C7 | **持久化路徑分裂**:Timeline 記近 20 個 localStorage key,Settings 幾乎不記 UI 狀態(tab/搜尋字串每次重置),Overlay 偏好走 config 而非 localStorage | 見各檔 | +| C8 | **點擊目標過小**(Apple HIG 偏離)— 🔧 **已實作 hit-slop**(2026-08-13):`.hit-target`/`.hit-target-v` 套用 14 處(Timeline 拖曳把手+標頭小鈕、Terminal 關閉/搜尋鈕、截圖刪除)。剩 `absolute` 定位的 filter 清除 × 與 Settings ListField × 待增量 | `styles/index.css` `.hit-target`、`Timeline.tsx:3571`、`App.tsx:1086` | +| C9 | **必要文字偏小**(Apple HIG 偏離)— 🔧 **已實作**(2026-08-13):Timeline 互動圖例與滾輪提示(必要導覽文字)由 `text-2xs`→`text-xs`;palette 分類標 `text-3xs`→`text-2xs`。其餘微字級經審視為裝飾性 meta,維持 | `Timeline.tsx:3023`(legend)、`Timeline.tsx:3064`(wheel hint) | + +--- + +## 4. 優先改善清單 + +**P0 — 結構性(直接回應「佔比可調」)** +1. ✅ **已實作**(2026-08-13):Sidebar 可收合(`⌘B`/toggle)+ 共用 ``(`lib/splitPane.ts` 純函式 + `components/SplitPane.tsx` + 7 例測試),已套用 FindingsView 左右欄。→ `DESIGN-SYSTEM.md §6.2/6.3` +2. ✅ **Terminal 分割窗已實作**(2026-08-13):`⌘D`/◫ 把一個 tab 分成兩個並排 shell(各自 pty),中間 SplitPane 可拖曳調整佔比;每 pane 可獨立關閉/重啟。(跨視圖 Timeline+Terminal 並排仍未做,需 app-shell 級變更。) +3. 🔧 Loot 已修(詳情面板完整值 + 複製,C1);IP/mark 點擊複製待做。 + +**P1 — 正確性/一致性** +4. Settings:「儲存」鈕與「自動儲存」文案二擇一;儲存失敗改為可見錯誤(C 見 §2.5)。 +5. Settings:token rotate/revoke、匯入 profile 補二次確認 + diff 預覽。 +6. Settings 搜尋補齊 `labelKeys`(讓 proxy/port/token 搜得到)。 +7. ScopeStatus 空狀態補 CTA;Timeline 加「0 筆結果/清除篩選」空狀態(C3)。 +8. 設計 token 收斂:修正殘留亮色 accent、統一字級(→ `DESIGN-SYSTEM.md §1–3`)。 +9. Apple HIG 對齊:✅ C8 命中區下限已實作(`.hit-target`/`.hit-target-v`,≥28px/把手 ≥8px);✅ C9 必要文字 ≥`text-xs`(圖例/滾輪提示已提級,微字級收窄為裝飾性 meta)(→ `DESIGN-SYSTEM.md §0.1/§2.3`)。 + +**P2 — 發現性打磨** +9. Timeline 滾輪模式改常駐視覺指示;軸切換對消失的 lane chip 給說明。→ `DESIGN-TIMELINE-DISCOVERABILITY.md` +10. Timeline 加真正的 fit-to-window。 +11. 關鍵手勢說明從原生 tooltip 升級為自訂 tooltip/圖例常駐文字(C5)。 +12. Modal focus trap(C6);跳轉按鈕在無 handler 時隱藏而非 disabled 佔位(C2)。 + +--- + +## 5. 純函式測試縫(沿用本 repo TDD 慣例) + +每項 UI 變更都應先落在一個純函式上,再接 UI(見 [`DEV-REQUIREMENTS-capture-onboarding.md`](DEV-REQUIREMENTS-capture-onboarding.md))。本輪建議的縫: + +- `splitPaneClamp(px, min, max, containerPx)` — 分割線位置夾擠(複用 Timeline 詳情面板既有邏輯)。 +- `settingsSearchIndex(groups)` — 由 group 定義+i18n 產出可搜尋 term(含 hint),取代手維護的 `labelKeys`。 +- `emptyStateFor(view, ctx)` 擴充 `filtered-empty` 分支(C3)。 +- `wheelMode(ctx)` 已存在(`lib/timelineWheel.ts`);發現性重設計在其之上加 `wheelModeLabel(ctx)`。 + +--- + +## 6. Backlog — 已實作 vs 未完成(2026-08-13 收尾) + +本輪從評估做到實作,狀態盤點如下。✅ 已實作並 commit(PR #8);⏳ 未做,供接續。 + +> **接續規劃(2026-08-13)**:下列 ⏳ 項目已在 [`UX-BACKLOG-TICKETS.md`](UX-BACKLOG-TICKETS.md) +> 拆成可獨立施作的 ticket——Timeline 發現性 → **T7**(滾輪 W1–W3)/**T8**(軸切換 A1–A3); +> Settings 正確性(§2.5)→ **S1**(儲存訊號)/**S2**(危險操作確認+diff)/**S3**(搜尋涵蓋率); +> 跨視圖(§3)→ **C1r/C2/C3/C4/C5/C6/C7**。每張 ticket 附純函式縫、驗收準則與測試檔名。 + +### ✅ 已實作 +- 設計 token 收斂:亮色 accent → `soften`(§1.5)、`text-2xs/3xs` 字級遷移(§2.3)、glyph → `lib/icons.ts`(§4.2 路線 B)。 +- Apple HIG:C8 `.hit-target` 命中區下限(§0.1a)、C9 必要文字 ≥`text-xs`。 +- 可調版面(§6):``(純函式 + 7 測試)、Findings/Loot/Transcript 清單|詳情、Loot/Transcript 完整值 + 複製(**C1 部分**)、Sidebar 收合(`⌘B`)、Terminal 分割窗(`⌘D`)。 + +### ⏳ 未完成(依類別) + +**文件已規劃、未實作** +- Timeline 發現性 W1–W3(滾輪常駐指示)+ A1–A3(軸切換分段控制、target 軸保留篩選)→ [`DESIGN-TIMELINE-DISCOVERABILITY.md`](DESIGN-TIMELINE-DISCOVERABILITY.md)。 +- 分類/狀態色盤統一(TerminalView ANSI、`KIND_COLOR`、Timeline fail 環)→ `DESIGN-SYSTEM.md §1.5b`。 +- 字級大遷移 `text-[11px]`(138 處)→ `text-xs`、`text-[13px]` → `text-sm`(需逐頁視覺 pass)→ `DESIGN-SYSTEM.md §2.3`。 +- icon 路線 A(導入 stroke icon set)→ `DESIGN-SYSTEM.md §4.2`。 + +**P1 — 正確性/一致性** +- Settings:`儲存`鈕 vs`自動儲存`訊號矛盾、儲存失敗靜默吞(§2.5)。 +- Settings:token rotate/revoke、匯入 profile 補二次確認 + diff(§2.5)。 +- Settings:搜尋涵蓋率(16/35 群組 `labelKeys: []`)→ 純函式縫 `settingsSearchIndex`(§5)。 +- C1 剩餘:IP/mark URL 點擊複製。 +- C3:ScopeStatus 空狀態補 CTA;Timeline「0 筆結果/清除篩選」空狀態 → `emptyStateFor` 加 `filtered-empty`(§5)。 + +**P2 — a11y/發現性** +- Modal focus-trap(C6);Settings tab bar `role=tablist`/方向鍵;搜尋結果 `aria-live`(§8)。 +- 跳轉按鈕在無 `onOpenInTimeline` 時隱藏而非 disabled 佔位(C2)。 +- 關鍵手勢從原生 `title` tooltip 升級為自訂 tooltip/常駐圖例(C5)。 + +**較大工程** +- 跨視圖 Timeline + Terminal 並排(app-shell 級版面變更;非本輪的終端內部分割)。 diff --git a/docs/UX-AUDIT-2026-08.md b/docs/UX-AUDIT-2026-08.md index 009f0f60..274346aa 100644 --- a/docs/UX-AUDIT-2026-08.md +++ b/docs/UX-AUDIT-2026-08.md @@ -1,5 +1,14 @@ # UX & Complexity Audit — 2026-08-10 +> **Refined 2026-08-11 by the necessity model (`DESIGN-PRINCIPLES.md`, roadmap +> "Necessity model & design decisions").** Two theses below are updated: (1) +> "not over-featured, under-sequenced" is now *mostly* under-sequenced — the +> plugin **marketplace** is a genuine over-build, **shelved**. (2) The HUD is not +> just an OPSEC add-on but a **co-headline second front door** with its own +> (P4) persona, so the onboarding findings (F1) are reframed as *value-first with +> evidence stated as the core* and a **HUD-only** path. Findings and line +> numbers below stand; read them through that lens. + Written against v0.11.5 by a full-tree read of `src/renderer/`, cross-checked against `PRODUCT-POSITIONING.md` (personas) and external research on how pentesters actually adopt tools. Companion to the correctness-focused diff --git a/docs/UX-BACKLOG-TICKETS.md b/docs/UX-BACKLOG-TICKETS.md index e0b1823e..6f0644f4 100644 --- a/docs/UX-BACKLOG-TICKETS.md +++ b/docs/UX-BACKLOG-TICKETS.md @@ -1,5 +1,25 @@ # UX Backlog — Ticket Specs +> **Updated 2026-08-11 by the necessity model (`DESIGN-PRINCIPLES.md`).** Three +> tickets change scope: **F3** (Settings) is now the *structural* reorg by +> necessity tier / front door, not just a search box. **F1** onboarding is +> *value-first with evidence stated as the core* + a **HUD-only** path (new: a +> first-class HUD-only runtime mode). The **T-series** is re-read through the +> timeline reframe (job = review; axis = target/phase; map vs. I/O reader — see +> `UX-TIMELINE-2026-08.md`'s banner). And a new decision: the plugin +> **marketplace is shelved** (product, not platform), so any marketplace polish +> is out of scope. Acceptance criteria below stand. + +> **Extended 2026-08-13 against v0.11.7.** The [`UX-AUDIT-2026-08-13.md`](UX-AUDIT-2026-08-13.md) +> resize/discoverability pass and [`DESIGN-TIMELINE-DISCOVERABILITY.md`](DESIGN-TIMELINE-DISCOVERABILITY.md) +> surfaced work that was written up as prose in that audit's §6 backlog but never +> broken into independently-implementable tickets. This revision closes that gap: +> new **T7/T8** (timeline wheel + axis discoverability), a new **S-series** +> (Settings correctness, audit §2.5) and a new **C-series** (cross-view pains, +> audit §3). It also reconciles the priority table below against what has since +> shipped — several F/T seams now exist and are wired, so their status moved off +> ⚪ (verified by import + test presence, 2026-08-13). + Written 2026-08-10 against v0.11.6. Each finding from `UX-AUDIT-2026-08.md` (F-series, whole renderer) and `UX-TIMELINE-2026-08.md` (T-series, timeline) is broken out here into an **independently implementable ticket**: enough spec, @@ -28,22 +48,54 @@ The fast, high-impact pairs first, structural work after. | Rank | Ticket | Why now | Effort | Status | |---|---|---|---|---| -| 1 | **F4** — wire the dead `EmptyState` into 6 views | Best effort:impact in the whole backlog; component already exists | S | ⚪ | +| 1 | **F4** — wire the dead `EmptyState` into 6 views | Best effort:impact in the whole backlog; component already exists | S | 🟡 | | 2 | **T1** — persistent interaction legend | Covers the 80% "invisible affordance" case in one strip | S | ⚪ | | 3 | **T2** — resolve the wheel-mode ambiguity | The single most disorienting timeline behaviour | S–M | ⚪ | | 4 | **F1-b** — guided hook install + verify | Finishes the onboarding arc F1 opened | M | ⚪ | | 5 | **T3** — active-modes row | Answers "why is my timeline empty" on-screen | M | ⚪ | -| 6 | **F3** — Settings filter box | Find, don't re-tab a 34-group page | S–M | ⚪ | -| 7 | **F5** — unified shortcut registry | Kills the split/inconsistent shortcut docs | S | ⚪ | +| 6 | **F3** — Settings filter box | Find, don't re-tab a 34-group page | S–M | 🟡 | +| 7 | **F5** — unified shortcut registry | Kills the split/inconsistent shortcut docs | S | 🟡 | | 8 | **F6** — discoverable search entry | Headline feature currently unfindable | S | ⚪ | | 9 | **T4** — header "View options" overflow menu | Header stops competing with itself | M | ⚪ | | 10 | **T5** — extract timeline pure seams + first interaction tests | Makes every later timeline change safe | M–L | 🟡 | | 11 | **F7** — sidebar reorder affordance | Minor discoverability polish | S | ⚪ | | 12 | **T6** — lane virtualization | Structural; also closes T2 permanently (= ROADMAP V4) | L | ⚪ | +| 13 | **PL1** — plugin lifecycle: one card, Install≠Enable | Removes the two-tab split + the orphaned-hook footgun | M–L | ⚪ | Already shipped in v0.11.6: **F1 (partial)** — Capture Readiness onboarding checklist; **T5 first seam** — the Timeline keyboard resolver. +**Reconciled 2026-08-13 (🟡, seam + test present, verify acceptance before closing):** +**F3** — `lib/settingsSearch.ts` + `test/settings-search.test.ts` exist and are +wired into `Settings.tsx`; the remaining gap is search *coverage*, now its own +ticket **S3**. **F4** — `lib/emptyState.ts` + `test/empty-state.test.ts` exist and +`EmptyState` is wired into Loot / Transcript / Findings; still missing from +Timeline / Screenshots / Marks / Targets, so F4 stays partial. **F5** — +`lib/shortcuts.ts` + `test/shortcuts.test.ts` exist but the registry renders only +in Timeline (`?`), not yet on the Dashboard card, so the two surfaces still +disagree. **T5** — more seams extracted since first-seam: `lib/timelineCluster.ts`, +`lib/laneVisibility.ts`, `lib/timelineAxis.ts`, `lib/timelineModes.ts` now exist. + +### Priority order — 2026-08-13 additions + +New tickets from the resize/discoverability pass. Ranked within themselves; slot +into the master list by effort:impact as capacity allows. + +| Rank | Ticket | Why now | Effort | Status | +|---|---|---|---|---| +| a | **S1** — one save signal + visible save failures | Contradictory save UI + silently-swallowed errors is a correctness bug, not polish | S | ⚪ | +| b | **C2** — hide dead jump-to-Timeline buttons | Disabled placeholders read as broken; cheapest correctness win | S | ⚪ | +| c | **C3** — filtered-empty + scope empty CTA | "Filtered to 0" looks like data loss; ScopeStatus empty state is a dead end | S–M | ⚪ | +| d | **S3** — fill Settings search coverage | Makes F3's box actually find proxy/port/token/regex | S–M | ⚪ | +| e | **T7** — persistent wheel-mode indicator (W1–W3) | Turns T2's post-scroll flash into a before-scroll, always-true cue | S–M | ⚪ | +| f | **C6** — modal focus trap | a11y correctness; Tab currently escapes dialogs | S–M | ⚪ | +| g | **C1r** — click-to-copy IP / mark URLs | Finishes C1 (Loot/Transcript copy already shipped) | S | ⚪ | +| h | **S2** — confirm + diff for destructive Settings actions | token rotate/revoke + whole-config profile import have no guard | M | ⚪ | +| i | **T8** — axis segmented control + preserved filtering (A1–A3) | Stops the silent lane-chip blanking on axis switch | M | ⚪ | +| j | **C4** — consistent filter-entry thresholds | Sparse-data surfaces hide filters you expect to exist | S | ⚪ | +| k | **C5** — custom tooltip / persistent gesture legend | Retires native `title` for key gestures (touch/keyboard reachable) | M | ⚪ | +| l | **C7** — unify UI-state persistence | Consistency, not a blocker — Settings/Overlay/ Timeline persist differently | M | ⚪ | + --- ## F-series — whole-renderer findings @@ -257,12 +309,318 @@ checklist; **T5 first seam** — the Timeline keyboard resolver. holds on a 100k-event engagement; T2's wheel branch is deleted. - **Effort:** L. **Note:** the one genuinely structural item; sequence last. +### T7 — Persistent wheel-mode indicator ⚪ *(supersedes T2's flash)* + +- **Persona:** P1. **Source:** UX-AUDIT-2026-08-13 §2.4; + `DESIGN-TIMELINE-DISCOVERABILITY.md` 第一部 (W1–W3). +- **Problem:** plain-wheel behaviour depends on the invisible lane-overflow state + (pan-x when the stack fits, scroll-y when it overflows, `⇧` to escape). The only + cue is a ~2.5s flash *after* the (often wrong) scroll, and T1's static legend + hard-codes "⌘-scroll to zoom" — it never mentions scroll-y or the `⇧` escape, so + the legend actively contradicts the live behaviour. +- **Proposed:** an always-on, low-weight indicator at the track's top-right that + reflects the current-context plain-wheel result **before** the scroll (W1), and + live-previews the modifier results while `⇧`/`⌘` is held (W2). All three + surfaces — T1 legend, the indicator, the preview — read one new pure + `wheelModeLabel(ctx)` (W3) so they can never drift. +- **Acceptance:** + - Before any scroll, the plain-wheel result is shown on-screen and updates as + overflow changes (window height / lane count / detail panel). + - Holding `⇧` or `⌘`/`Ctrl` live-previews that modifier's result; releasing + restores. Under `prefers-reduced-motion` the swap is instant, no transition. + - Legend + indicator + preview text are all produced by `wheelModeLabel`; no + hard-coded gesture string remains. No regression to cursor-anchored `⌘-scroll` + zoom. +- **Seam:** `lib/timelineWheel.ts` — add `wheelModeLabel(ctx): WheelModeView` + (`{ mode, labelKey, escapeHintKey? }`) derived from the existing `wheelMode(ctx)`; + extend `test/timeline-wheel.test.ts` over the same 4-row matrix asserting each + row's `labelKey`/`escapeHintKey`. UI reads live `overflow`. +- **Effort:** S–M. **Relates:** replaces T2's flash with a persistent cue; **T6** + later deletes the overflow branch, at which point the indicator degrades to + "`⇔ pan · ⌘ zoom`". + +### T8 — Axis segmented control + preserved filtering ⚪ + +- **Persona:** P1. **Source:** UX-AUDIT-2026-08-13 §2.4; + `DESIGN-TIMELINE-DISCOVERABILITY.md` 第二部 (A1–A3). +- **Problem:** the source↔target lane axis is a cryptic `⊞` toggle. Switching to + the target axis **silently blanks the entire lane-chip row** (the + `laneAxis === 'source' &&` gate at `Timeline.tsx:2925`) and **orphans + `hiddenLanes`** — the "Show all" restore button survives but the per-lane chips + that could bring a hidden lane back are gone. +- **Partly done since the audit:** the toggle now renders a text label + (`timeline.axisSource`/`axisTarget`, `Timeline.tsx:2664`), so A1 is *partially* + met; A2 (preserved filtering) and A3 (transition chip) remain. +- **Proposed:** + - **A1** — replace the `⊞` toggle with a two-segment control (`來源 | 目標`), + current segment highlighted, hover explains each axis. Deep-links ("Targets" → + target axis) land with the control already showing the 目標 segment. + - **A2** — under the target axis, render **equivalent per-host lane chips** + (reuse `lib/laneVisibility.ts`, generalised to the target lane set) so + `hiddenLanes` operates on the current axis's lanes and is never orphaned; keep + the "Show all" restore. (Fallback (b): if per-host chips are deferred, show a + one-line "來源篩選暫不適用 · [切回來源軸]" instead of a silent blank.) + - **A3** — when the axis is non-default (target), emit one dismissible + `⊞ 目標軸 ✕` chip in the T3 active-modes row whose `✕` returns to the source + axis, so "why does my timeline look different" is stated on-screen. +- **Acceptance:** + - The current axis is always visible and text-labelled. + - The target axis keeps equivalent filtering with a guaranteed restore path; + hidden lanes are never orphaned. + - A non-default axis shows exactly one clear-in-one-click chip; returning to + source removes the chip and restores the source lane chips. + - Invariant preserved: all-default (source axis, no sticky modes) → no "Active:" + row (continues T3). i18n parity for all new keys. +- **Seam:** `lib/timelineAxis.ts` (exists) for A1; `lib/laneVisibility.ts` + (exists, generalise to target lanes) for A2; `lib/timelineModes.ts` — add a + `laneAxis: 'source' | 'target'` field so `activeModes` yields one chip on + `target` and `[]` on `source`. Extend `test/timeline-modes` (or add it). +- **Effort:** M. **Depends on:** T3 (active-modes row) for A3. + +--- + +## S-series — Settings correctness (audit §2.5) + +### S1 — One save signal; surface save failures ⚪ + +- **Persona:** P1. **Source:** UX-AUDIT-2026-08-13 §2.5. +- **Problem:** auto-save, a manual **儲存** button, and a **"會自動儲存"** hint all + coexist (`Settings.tsx:738-755`) — three contradictory signals about whether the + user must act. Worse, save failure is swallowed by `.catch(() => {})` + (`Settings.tsx:160`): a failed save looks identical to a successful one. +- **Proposed:** commit to one model (auto-save is canonical). Drop the redundant + manual button — or demote it to a "Save now" that only appears while a save is + pending or has failed — and show a single status line: `Saved` / `Saving…` / + `Save failed — retry`. Replace the empty catch with a visible, retryable state. +- **Acceptance:** + - At most one save affordance/message is visible at a time; no simultaneous + "auto-saves" hint next to a manual save button. + - A forced save failure renders a visible, retryable error — never a silent + no-op. i18n parity for the new status strings. +- **Seam:** `lib/saveStatus.ts` — a tiny `saveStatusReducer(state, event)` over + `idle | saving | saved | error`; test the transitions (incl. error→retry→saved). + The status line and button visibility render off it. +- **Effort:** S. + +### S2 — Confirm + diff for destructive Settings actions ⚪ + +- **Persona:** P1 / P3. **Source:** UX-AUDIT-2026-08-13 §2.5. +- **Problem:** only **刪除操作員** has a `confirmDialog` (`Settings.tsx:1789`). + **Token rotate**, **revoke**, and **profile import** (`Settings.tsx:465-467` — a + whole-config overwrite with **no diff preview**) all apply with no confirmation. +- **Proposed:** reuse the existing `confirmDialog` for token rotate + revoke. For + profile import, show a **diff preview** (keys added / changed / removed) behind + the confirm, so the operator sees exactly what a profile will overwrite before + applying. +- **Acceptance:** + - Rotate, revoke, and import each require an explicit confirmation. + - Import shows a before/after diff and can be cancelled with **zero** state + change. i18n parity for the new keys. +- **Seam:** `lib/settingsProfileDiff.ts` — pure + `diffProfile(current, incoming) → { added, changed, removed }`; test it before + the dialog renders it. +- **Effort:** M. + +### S3 — Fill Settings search coverage ⚪ *(extends F3)* + +- **Persona:** P1. **Source:** UX-AUDIT-2026-08-13 §2.5; §5 seam note. +- **Problem:** F3's filter box exists, but **16 of 35 groups have + `labelKeys: []`** (~46%) — so searching real fields (`proxy`, `port`, `token`, + `regex`) returns nothing; only group *titles* are searchable. The box is + under-fed by a hand-maintained index. +- **Proposed:** replace hand-maintained `labelKeys` with a **generated** + `settingsSearchIndex(groups)` derived from the group definitions + i18n, so every + rendered field (and its hint text) is searchable without manual upkeep. +- **Acceptance:** + - Searching `proxy` / `port` / `token` / `regex` each surface their owning group + from any tab. + - No group ships with empty searchable terms; the index is generated, not + hand-listed. +- **Seam:** `lib/settingsSearch.ts` (exists) — add + `settingsSearchIndex(groups)`; extend `test/settings-search.test.ts` to assert + the four example terms resolve to the right group. +- **Effort:** S–M. + +--- + +## C-series — cross-view pains (audit §3) + +### C1r — Click-to-copy for IP / mark URLs ⚪ + +- **Persona:** P1. **Source:** UX-AUDIT-2026-08-13 §3 C1 (remainder). +- **Problem:** Loot / Transcript detail now show full values + a copy button + (shipped 2026-08-13), but **IP addresses and mark URLs still can't be copied by + click** — the operator re-types them. +- **Proposed:** a shared `` (or `useCopy()` hook) applied to IP + cells (`IPStatusCard` / Timeline) and mark URLs, with an inline `✓` confirmation + mirroring the HUD `⚡` quick-mark pattern (no focus steal). +- **Acceptance:** clicking an IP or a mark URL copies it with a visible, transient + confirmation; keyboard-activatable; no layout shift on confirm. +- **Seam:** `lib/clipboard.ts` thin wrapper (or reuse the existing copy util from + the Loot/Transcript work); the value-formatting fn is the tested part. +- **Effort:** S. + +### C2 — Hide dead jump-to-Timeline buttons ⚪ + +- **Persona:** P1. **Source:** UX-AUDIT-2026-08-13 §3 C2. +- **Problem:** `onOpenInTimeline` is optional; when a parent doesn't pass it, the + button renders **disabled-but-present** (`SearchPanel.tsx:122`, + `ScopeStatus.tsx:95`, `LootPanel.tsx:169`) — a dead placeholder that reads as + broken. +- **Proposed:** render the jump button only when a handler exists (preferred), or + always wire the handler. No disabled placeholder. +- **Acceptance:** no disabled jump-to-Timeline placeholder is ever shown; when the + button is present it always navigates. +- **Seam:** none pure; a render test per panel asserting presence-iff-handler. +- **Effort:** S. + +### C3 — Filtered-empty state + ScopeStatus empty CTA ⚪ + +- **Persona:** P1. **Source:** UX-AUDIT-2026-08-13 §3 C3; §5 seam note. +- **Problem:** two dead ends. (1) **Timeline "filtered to 0"** is pixel-identical + to "genuinely no events" (`Timeline.tsx:2442`), with no "clear filters?" path — + it reads as data loss. (2) **ScopeStatus with no scope set** is icon + text with + no CTA (`ScopeStatus.tsx:69`). +- **Proposed:** extend `emptyStateFor` with a `filtered-empty` branch that offers a + **"clear filters"** action; give the ScopeStatus empty state a **"set scope"** + CTA (nav to Settings ▸ Scope). +- **Acceptance:** + - A filtered-to-zero timeline shows a distinct "clear filters" action that + restores results; a genuinely-empty timeline keeps its own copy. + - ScopeStatus's no-scope state offers a working CTA. i18n parity. +- **Seam:** `lib/emptyState.ts` (exists) — add the `filtered-empty` branch; extend + `test/empty-state.test.ts`. +- **Effort:** S–M. + +### C4 — Consistent filter-entry thresholds ⚪ + +- **Persona:** P1. **Source:** UX-AUDIT-2026-08-13 §3 C4. +- **Problem:** the filter UI appears at **different data thresholds** per view — + Findings search needs marks ≥ 5, Loot chips need types ≥ 2, Search chips need + types > 1 (`FindingsView.tsx:136`, `LootPanel.tsx:121`) — so with sparse data a + filter you expect to exist is simply missing, inconsistently. +- **Proposed:** one policy across Findings / Loot / Search (e.g. reveal the filter + affordance whenever there is more than one distinct value, otherwise omit it), + documented in one place. +- **Acceptance:** the three surfaces reveal their filter at the same, documented + threshold; no surface hides a filter that another would show for the same data + shape. +- **Seam:** `lib/filterAffordance.ts` — `shouldShowFilter(distinctCount, policy)`; + test the boundary cases. +- **Effort:** S. + +### C5 — Custom tooltip / persistent gesture legend ⚪ + +- **Persona:** P1. **Source:** UX-AUDIT-2026-08-13 §3 C5. +- **Problem:** key gestures ride on native `title` tooltips — ~1s delay, + unstyleable, and **invisible to touch and keyboard users**. +- **Proposed:** a lightweight `` (hover **and** focus, no delay) for the + handful of gesture hints, and/or fold the core gestures into the persistent + legend (**T1**). Native `title` retired for these gesture affordances. +- **Acceptance:** gesture hints are keyboard-focusable and appear without the + native delay; no key gesture is documented *only* through `title`. +- **Seam:** UI component; a render + focus test. +- **Effort:** M. **Overlaps:** T1 (persistent legend). + +### C6 — Modal focus trap ⚪ + +- **Persona:** P1 (a11y). **Source:** UX-AUDIT-2026-08-13 §3 C6. +- **Problem:** modals (e.g. `EventMarker.tsx:31`) don't trap focus — Tab escapes + the open dialog into the page behind it. +- **Proposed:** a `useFocusTrap(ref)` hook on modal roots: cycle Tab / Shift-Tab + within the modal, restore focus to the opener on close, Esc closes. +- **Acceptance:** Tab and Shift-Tab stay within the open modal; focus returns to + the triggering control on close; Esc closes. +- **Seam:** `lib/focusTrap.ts` — the focusable-order / wrap math is pure and + testable; the hook wires it to the DOM. +- **Effort:** S–M. + +### C7 — Unify UI-state persistence ⚪ *(consistency, not a blocker)* + +- **Persona:** maintainer / P1. **Source:** UX-AUDIT-2026-08-13 §3 C7. +- **Problem:** persistence is split three ways — Timeline keeps ~20 localStorage + keys, Settings **resets tab + search on every open**, Overlay preferences go + through config rather than localStorage. Same concept, three mechanisms. +- **Proposed:** a small `uiState` helper with a namespaced key convention; migrate + Settings tab/search and Overlay *UI* prefs onto it (Overlay's *functional* prefs + stay in config, deliberately). +- **Acceptance:** Settings reopens on its last tab + search string; one documented + key convention; no two sources persist the same preference. +- **Seam:** `lib/uiState.ts` — namespaced get/set with default fallback; test the + namespacing + private-mode fallback. +- **Effort:** M. **Note:** lowest of the C-series — consistency polish. + +--- + +## PL-series — plugin & capture lifecycle + +### PL1 — Unify the plugin lifecycle into one card; disambiguate Install ≠ Enable ⚪ + +- **Persona:** P1 / P3. **Source:** `CAPTURE-SOURCE-TAXONOMY.md` §Part 4 (four + gates); this session's UI review of `Settings.tsx`. +- **Problem:** a plugin's lifecycle is **split across two tabs with overloaded + wording**, and one gate has a silent footgun: + - Enable/trust (gates 2–3) live in **Plugins tab** (`PluginsPanel`); capture-hook + install (gate 4) lives in **Capture tab → Hooks detected** (`HooksPanel`). A + plugin that contributes a capture hook appears in **both**, with no + cross-link — the operator can enable it and never realise its hook isn't wired + ("enabled but not recording" is invisible). + - The word **"Enable/Disable" is overloaded**: in Plugins it means gate 2 + (in-process, reversible, zero external footprint); the Hooks button labelled + `hookEnable`/`hookDisable` (`Settings.tsx` L878) actually means gate 4 — + **writing into `.zshrc` / `~/.claude/settings.json`**, external state that + outlives RedLog. + - **Disable ≠ Uninstall footgun:** `setPluginEnabled(false)` (`plugins/index.ts`) + only flips `state.json`; it does **not** remove the hook line. Disabling or + deleting a plugin **leaves orphaned hook lines** in the operator's shell/agent + config that keep firing whether or not RedLog is open. +- **Proposed (full refactor — optimise for final effect, not minimal diff):** + - **One lifecycle card per plugin** (in Plugins tab) rendering all four gates as + a single progression: `Present → Enabled → (Trusted, 🔴) → Hook installed`, + with the current state and the *one* next action surfaced. A contributed + capture hook shows its install state inline on the same card (no tab hop). + - **Two distinct verbs, never shared:** **Enable/Disable** for gate 2 only; + **Install/Uninstall** for gate 4 (the button that writes external config). + Rename the Hooks button accordingly and restyle it to read as a + system-mutating action (it edits files outside RedLog). + - **"Enabled but not wired" is a first-class, legible state** with a clear + "Install hook to start capturing" CTA — reuse the `captureReadiness` state + machine so this ties into onboarding (F1/F1-b). + - **Kill the orphan:** disabling/deleting a plugin that has installed hooks + prompts to uninstall them (or clearly flags them as still-installed with a + one-click cleanup). Hook uninstall is always its own explicit action. + - The Capture-tab Hooks list may remain as the *system-wide* hook inventory, but + plugin-contributed hooks deep-link to/from their plugin card so the two views + agree. +- **Acceptance:** + - A plugin with a capture hook shows enable-state **and** hook-install-state on + one card; the operator never needs both tabs to get it capturing. + - No control labelled "Enable" writes to `.zshrc`/`.claude/settings.json`; every + such write is labelled "Install" and visually marked as external. + - Disabling or deleting a plugin with an installed hook cannot silently leave an + orphaned hook line — the flow either removes it or surfaces it with a cleanup + action. + - "Enabled but hook not installed" renders a distinct state with a next-step CTA. + - i18n parity: all new keys in `en.json` and `zh-TW.json`, same edit. +- **Seam (house rule — pure function first):** `lib/pluginLifecycle.ts` — + `computePluginLifecycle(plugin, hooks, trust)` → a view model + `{ present, enabled, trusted, hooks: [{id, installed}], nextAction, orphanedHooks }` + merging `PluginView` (from `window.redlog.plugins`) with hook state (from + `window.redlog.hooks` / `capture-health`). Test `test/plugin-lifecycle.test.ts` + before the card renders/dispatches it; reuse `computeCaptureReadiness` for the + wired/active transitions. +- **Effort:** M–L. **Depends on:** none hard; composes with F1-b (guided install) + and the `captureReadiness` seam. + --- ## Cross-references - Personas + non-goals: `PRODUCT-POSITIONING.md` - Whole-renderer audit: `UX-AUDIT-2026-08.md` +- Resize / discoverability audit (T7/T8, S-, C-series source): `UX-AUDIT-2026-08-13.md` - Timeline deep-dive: `UX-TIMELINE-2026-08.md` +- Wheel + axis discoverability spec (T7/T8 detail): `DESIGN-TIMELINE-DISCOVERABILITY.md` +- Design system (tokens / layout / HIG targets): `DESIGN-SYSTEM.md` - TDD process + worked example: `DEV-REQUIREMENTS-capture-onboarding.md` - Correctness/trust/scale roadmap: `ROADMAP.md` (UX track added 2026-08-10) diff --git a/docs/UX-TIMELINE-2026-08.md b/docs/UX-TIMELINE-2026-08.md index f30e0aaf..847fa6db 100644 --- a/docs/UX-TIMELINE-2026-08.md +++ b/docs/UX-TIMELINE-2026-08.md @@ -1,5 +1,16 @@ # Timeline UX — Why It Feels Complex, and How to Simplify +> **Reframed 2026-08-11 by the necessity model (`DESIGN-PRINCIPLES.md` §8).** +> Three decisions now sit *above* the T-series fixes below and change what +> "simpler" means: (1) the timeline's job is **post-hoc reconstruction/review** — +> live awareness goes to the HUD — so it sheds live-ops weight (follow-mode +> urgency, live-tail primacy). (2) Its organizing axis becomes **target/phase, +> not source-type**; the 18 source-type lanes demote to a filter and TargetView +> folds in — which dissolves the 18-colour problem at its root. (3) The timeline +> is the **event map**; the transcript/exchange is the **I/O reader**; they link +> by drill-down, so the timeline never grows inline I/O content. The specific +> T1–T6 fixes below remain valid *within* this reframing. + Written 2026-08-10 against v0.11.5, from a full read of `Timeline.tsx`. This is the deep-dive behind finding **F2** in `UX-AUDIT-2026-08.md`. The complaint — "the timeline is too complex and not intuitive to operate" — is correct and, on diff --git a/docs/deconfliction.md b/docs/deconfliction.md index 5e47306d..f1645f40 100644 --- a/docs/deconfliction.md +++ b/docs/deconfliction.md @@ -32,12 +32,31 @@ deconfliction: # worth forwarding to the SOC — uncomment as needed: # - ip_transition # external IP or safety state (safe/exposed/unknown) changed # - opsec_state_changed # VPN interfaces / DNS resolvers / MAC / hostname changed - # - config_changed # scope / blacklist / enforcement changed (with a from→to diff) + # - config_changed # scope / blacklist / alertFloor changed (with a from→to diff) + # - scope_loaded # which scope document was loaded, with its sha256 # - recording_paused # operator paused recording — explains any timeline gap # - recording_resumed includeData: false # if true, include full event.data — mind PII + authorityFloor: inferred # inferred (default) = forward both tiers, labelled + # fact = observed rule matches only ``` +### Authority tiers + +Every forwarded event carries an `authority` field so the receiver can tell an +**observed rule match** from an **inference**: + +| `authority` | Meaning | Example | +|---|---|---| +| `fact` | a configured rule matched literally | `reason: excluded_target` — a host the engagement explicitly forbade | +| `inferred` | RedLog judged it noteworthy by proximity | `reason: adjacent_subnet` / `adjacent_domain` — out of scope and near a scope entry | +| absent | the event type makes no §3 claim | markers, credential use, C2 check-ins | + +Both tiers describe activity that really happened; the tier is about how confident +RedLog is that it *matters*, not about whether it occurred. The default forwards +both — narrowing what the blue team is told is a deliberate act. `authorityFloor: +fact` holds inferences back; events with no `authority` are unaffected either way. + ## Payload ```json @@ -53,10 +72,17 @@ deconfliction: "subtype": "successful_login", "description": "Reused leaked svc-backup credentials to establish VPN tunnel", "severity": "high", + "authority": "fact", + "reason": null, "mitre_ttp": "T1078" } ``` +`authority` and `reason` are bounded enums, so they are sent **outside** the +`includeData` gate — a receiver can triage a `scope_violation` without being handed +the command text. `reason` is `excluded_target` | `adjacent_subnet` | +`adjacent_domain` for scope violations, and `null` for everything else. + Header: ``` diff --git a/e2e/ip-alert.spec.ts b/e2e/ip-alert.spec.ts new file mode 100644 index 00000000..4b7e16cf --- /dev/null +++ b/e2e/ip-alert.spec.ts @@ -0,0 +1,116 @@ +import { test, expect, _electron as electron, type ElectronApplication, type Page } from '@playwright/test' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { MAIN_ENTRY, REPO_ROOT, openTestProject } from './helpers' + +// The alert path through real windows: a verdict arrives on `ip:status` and both +// the HUD overlay and the main window's status bar have to change. The unit +// tests (`alert-display`, `alert-surfaces`) assert the same rendering against a +// mocked bridge; this spec proves the IPC channel, the preload subscription and +// the real compositor are wired to it too — the layer where "it renders in jsdom +// but the operator sees nothing" would hide. +// +// The verdict is pushed directly rather than provoked by a real egress lookup: +// the classifier already has exhaustive unit coverage, and an e2e run must not +// depend on the machine's actual network position. + +let app: ElectronApplication +let hud: Page +let main: Page + +interface Status { + externalIP: string | null + internalIP: string | null + ipSafety: 'safe' | 'exposed' | 'unknown' + lastCheck: number + error: string | null + settling: boolean +} + +const status = (ipSafety: Status['ipSafety'], externalIP = '203.0.113.7'): Status => ({ + externalIP, internalIP: '10.0.0.2', ipSafety, lastCheck: Date.now(), error: null, settling: false +}) + +/** Push a verdict to every window, exactly as `broadcastIPStatus` does. */ +async function pushStatus(s: Status): Promise { + await app.evaluate(async ({ BrowserWindow }, payload) => { + for (const w of BrowserWindow.getAllWindows()) { + if (!w.isDestroyed()) w.webContents.send('ip:status', payload) + } + }, s) + await hud.waitForTimeout(150) +} + +/** Inline background of the HUD frame — the element carrying the verdict. */ +function frame(page: Page): ReturnType { + return page.locator('body > div > div > div').first() +} + +test.describe.serial('IP verdict reaches the operator', () => { + test.beforeAll(async () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'redlog-ipalert-')) + app = await electron.launch({ + args: [MAIN_ENTRY], cwd: REPO_ROOT, + env: { ...process.env, NODE_ENV: 'test', HOME: tmpHome, USERPROFILE: tmpHome, REDLOG_E2E: '1' } + }) + main = await app.firstWindow() + await main.waitForLoadState('domcontentloaded') + await openTestProject(main, 'ip-alert') + await main.waitForTimeout(2000) + hud = app.windows().find((w) => w.url().includes('overlay'))! + expect(hud, 'overlay window never opened').toBeTruthy() + }) + + test.afterAll(async () => { if (app) await app.close() }) + + test('EXPOSED turns the HUD frame red and flashes it', async () => { + await pushStatus(status('exposed')) + await expect(hud.getByText('EXPOSED').first()).toBeVisible() + const style = await frame(hud).getAttribute('style') + expect(style).toContain('rgb(215, 95, 99)') // HUD.red + expect(style).toContain('alarm') // the flash keyframes + }) + + test('the same verdict reaches the status bar in the main window', async () => { + await expect(main.getByText('EXPOSED').first()).toBeVisible() + await expect(main.getByText('203.0.113.7').first()).toBeVisible() + }) + + test('SAFE clears the alarm and returns the frame to cyan', async () => { + await pushStatus(status('safe')) + await expect(hud.getByText('SAFE').first()).toBeVisible() + const style = await frame(hud).getAttribute('style') + expect(style).toContain('rgb(63, 199, 214)') // HUD.cyan + expect(style).not.toContain('alarm') + }) + + test('UNKNOWN is amber and distinct from SAFE — a dropped VPN is not green', async () => { + await pushStatus(status('unknown')) + await expect(hud.getByText('IP?').first()).toBeVisible() + await expect(main.getByText('IP?').first()).toBeVisible() + }) + + test('turning flashOnExposed off stops the flash without a restart', async () => { + await main.evaluate(async () => { + const bridge = (window as unknown as { + redlog: { config: { get: () => Promise>; save: (c: unknown) => Promise } } + }).redlog + const cfg = await bridge.config.get() + const overlay = (cfg.overlay ?? {}) as Record + await bridge.config.save({ ...cfg, overlay: { ...overlay, flashOnExposed: false } }) + }) + await hud.waitForTimeout(400) + + await pushStatus(status('exposed')) + const style = await frame(hud).getAttribute('style') + expect(style).toContain('rgb(215, 95, 99)') // still unmistakably red + expect(style).not.toContain('alarm') // but no longer flashing + }) + + test('a missing reading shows an em dash, never a stale address', async () => { + await pushStatus({ ...status('unknown'), externalIP: null, internalIP: null }) + await expect(hud.getByText('—').first()).toBeVisible() + await expect(main.getByText('203.0.113.7')).toHaveCount(0) + }) +}) diff --git a/e2e/mitmproxy-capture.spec.ts b/e2e/mitmproxy-capture.spec.ts new file mode 100644 index 00000000..61cfb11b --- /dev/null +++ b/e2e/mitmproxy-capture.spec.ts @@ -0,0 +1,154 @@ +// End-to-end mitmproxy capture. Real flow: +// curl → mitmdump (hooks/mitmproxy-addon.py) → local echo server +// then assert RedLog captured the http_request_start + http_response scanner +// events, INCLUDING the request AND response bodies. Isolated temp HOME. +// +// Skips unless mitmdump is available: set REDLOG_MITMDUMP=/path/to/mitmdump (or +// have `mitmdump` on PATH). `pipx install mitmproxy` / a venv both work. +import { test, expect } from '@playwright/test' +import { launchWithTempHome, openTestProject } from './helpers' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { spawn, spawnSync } from 'node:child_process' +import http from 'node:http' +import net from 'node:net' +import type { AddressInfo } from 'node:net' + +const MITMDUMP = process.env.REDLOG_MITMDUMP ?? 'mitmdump' +function mitmdumpAvailable(): boolean { + try { return spawnSync(MITMDUMP, ['--version'], { timeout: 10_000 }).status === 0 } catch { return false } +} + +// Resolves once something is accepting TCP on the port, or throws after timeout. +async function waitForPort(port: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + for (;;) { + const ok = await new Promise((resolve) => { + const s = net.connect(port, '127.0.0.1') + s.once('connect', () => { s.destroy(); resolve(true) }) + s.once('error', () => resolve(false)) + }) + if (ok) return + if (Date.now() > deadline) throw new Error(`port ${port} never came up`) + await new Promise((r) => setTimeout(r, 300)) + } +} + +const PROXY_PORT = 8899 + +test('mitmproxy addon captures request + response bodies end to end', async () => { + test.skip(!mitmdumpAvailable(), 'mitmdump not installed (set REDLOG_MITMDUMP or add to PATH)') + test.setTimeout(60_000) + + // 1) A local echo server that reflects the request body in a JSON response. + const echo = http.createServer((req, res) => { + let body = '' + req.on('data', (c) => (body += c)) + req.on('end', () => { + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ echoedBody: body, method: req.method, path: req.url })) + }) + }) + await new Promise((r) => echo.listen(0, '127.0.0.1', () => r())) + const echoPort = (echo.address() as AddressInfo).port + + // 2) RedLog up (API server + token/port on disk under the temp HOME). + const { app, page, tmpHome } = await launchWithTempHome() + await openTestProject(page, 'mitm-verify') + const apiPort = readFileSync(join(tmpHome, '.redlog', 'api-port'), 'utf8').trim() + const token = readFileSync(join(tmpHome, '.redlog', 'api-token'), 'utf8').trim() + + // 3) mitmdump with the addon. HOME=tmpHome so the addon reads THIS RedLog's + // api-port/token. block_global=false allows proxying to loopback. + const mitm = spawn( + MITMDUMP, + ['-s', join(process.cwd(), 'hooks', 'mitmproxy-addon.py'), + '--listen-port', String(PROXY_PORT), '--set', 'block_global=false'], + { env: { ...process.env, HOME: tmpHome, USERPROFILE: tmpHome, REDLOG_VERBOSE: 'true' }, stdio: 'pipe' } + ) + let mitmLog = '' + mitm.stdout?.on('data', (b: Buffer) => (mitmLog += b.toString())) + mitm.stderr?.on('data', (b: Buffer) => (mitmLog += b.toString())) + await waitForPort(PROXY_PORT, 25_000) // wait until the proxy is actually up + await new Promise((r) => setTimeout(r, 500)) + + // 4) A POST with a JSON body, through the proxy, to the echo server. Async + // (not execFileSync) — the echo server lives in THIS node event loop, so a + // synchronous curl would block it and deadlock (proxy → echo can't answer). + const curlOut = await new Promise((resolve, reject) => { + const c = spawn('curl', [ + '-s', '-x', `http://127.0.0.1:${PROXY_PORT}`, + '-X', 'POST', `http://127.0.0.1:${echoPort}/api/login`, + '-H', 'content-type: application/json', + '-d', '{"user":"admin","pass":"s3cr3t-token"}' + ]) + let out = '' + c.stdout.on('data', (b: Buffer) => (out += b.toString())) + c.on('close', () => resolve(out)) + c.on('error', reject) + setTimeout(() => { c.kill(); reject(new Error('curl timed out')) }, 15_000) + }) + console.log('[mitm] curl got:', curlOut.slice(0, 120)) + + // 4b) io_ref sidecar (SPEC-IO-SIDECAR.md A1/A2): a request body larger than + // the 16 KB inline preview cap. The echo reflects it, so BOTH directions + // exceed the cap and should sidecar. Assert the event keeps only a digest. + const bigValue = 'B'.repeat(40 * 1024) // > REDLOG_MAX_BODY (16 KB) + await new Promise((resolve, reject) => { + const c = spawn('curl', [ + '-s', '-x', `http://127.0.0.1:${PROXY_PORT}`, + '-X', 'POST', `http://127.0.0.1:${echoPort}/api/upload`, + '-H', 'content-type: application/json', + '-d', JSON.stringify({ blob: bigValue }) + ]) + c.on('close', () => resolve()) + c.on('error', reject) + setTimeout(() => { c.kill(); reject(new Error('curl(big) timed out')) }, 15_000) + }) + await new Promise((r) => setTimeout(r, 2500)) // addon posts to RedLog async + + // 5) Read back the scanner events RedLog stored. + const res = await fetch(`http://127.0.0.1:${apiPort}/api/events?agent_type=scanner&limit=50`, { + headers: { Authorization: `Bearer ${token}` } + }) + const payload = await res.json() as unknown + const raw = Array.isArray(payload) ? payload : ((payload as { events?: unknown[] }).events ?? []) + const events = (raw as Array<{ data: unknown }>).map((e) => ({ + data: (typeof e.data === 'string' ? JSON.parse(e.data) : e.data) as Record + })) + const req = events.find((e) => e.data?.subtype === 'http_request_start' && String(e.data?.url).includes('/api/login')) + const rsp = events.find((e) => e.data?.subtype === 'http_response' && String(e.data?.url).includes('/api/login')) + const bigReq = events.find((e) => e.data?.subtype === 'http_request_start' && String(e.data?.url).includes('/api/upload')) + const bigRsp = events.find((e) => e.data?.subtype === 'http_response' && String(e.data?.url).includes('/api/upload')) + + console.log('[mitm] scanner events:', events.length, + 'req?', !!req, 'rsp?', !!rsp, + '\n[mitm] mitmdump log tail:', mitmLog.split('\n').slice(-6).join(' | ')) + + mitm.kill('SIGTERM') + echo.close() + + // Assertions: both directions captured, with bodies. + expect(req, 'http_request_start event').toBeTruthy() + expect(rsp, 'http_response event').toBeTruthy() + expect(String(req?.data?.method)).toBe('POST') + expect(String(req?.data?.request_body_preview)).toContain('s3cr3t-token') + expect(String(rsp?.data?.status)).toBe('200') + expect(String(rsp?.data?.response_preview)).toContain('echoedBody') + // the flow is paired via flow_id → _causes on the response + expect(req?.data?.flow_id).toBeTruthy() + expect(rsp?.data?.flow_id).toBe(req?.data?.flow_id) + + // io_ref sidecar: the >16 KB bodies carry a digest-only ref, not raw bytes, + // and the preview is truncated while the ref records the full length (A1/A2). + const reqIo = (bigReq?.data?.io as { request?: { ref: string; len: number } } | undefined)?.request + const rspIo = (bigRsp?.data?.io as { response?: { ref: string; len: number } } | undefined)?.response + expect(reqIo?.ref, 'request body sidecarred').toMatch(/^[0-9a-f]{64}$/) + expect(reqIo!.len).toBeGreaterThan(16 * 1024) + expect(bigReq?.data?.request_body_full, 'raw request bytes not chained').toBeUndefined() + expect(String(bigReq?.data?.request_body_preview)).toContain('...[truncated') + expect(rspIo?.ref, 'response body sidecarred').toMatch(/^[0-9a-f]{64}$/) + expect(rspIo!.len).toBeGreaterThan(16 * 1024) + + await app.close() +}) diff --git a/e2e/timeline-axis.spec.ts b/e2e/timeline-axis.spec.ts new file mode 100644 index 00000000..88c66ee6 --- /dev/null +++ b/e2e/timeline-axis.spec.ts @@ -0,0 +1,100 @@ +import { test, expect, type ElectronApplication, type Page } from '@playwright/test' +import { launchWithTempHome, openTestProject } from './helpers' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +// Phase C lane-axis: the timeline can group rows by source type (default) or by +// target. This drives the real app because the two crashes we hit building it — +// a dropped import and a `for (const lane of LANES)` that indexed a target-keyed +// map — both passed `npm run build` and the renderer-smoke unit test; only +// launching the app surfaced them. So the regression guard has to launch too. + +let app: ElectronApplication +let page: Page +let tmpHome = '' + +const shellLane = (): ReturnType => page.getByText('Shell').first() + +test.describe.serial('timeline lane axis', () => { + test.beforeAll(async () => { + ;({ app, page, tmpHome } = await launchWithTempHome()) + await openTestProject(page, 'axis-e2e') + + const token = readFileSync(join(tmpHome, '.redlog', 'api-token'), 'utf8').trim() + const port = readFileSync(join(tmpHome, '.redlog', 'api-port'), 'utf8').trim() + const post = (body: unknown): Promise => + fetch(`http://127.0.0.1:${port}/api/events`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + // Two targets + one untargeted, across source types: source shows + // Shell/Scanner/DNS; target shows two hosts + the untargeted lane. + for (const e of [ + { agent_type: 'shell', target_id: '10.0.0.5', data: { subtype: 'command_start', command: 'nmap 10.0.0.5' } }, + { agent_type: 'scanner', target_id: '10.0.0.5', data: { subtype: 'http_request', url: 'http://10.0.0.5/' } }, + { agent_type: 'dns', target_id: 'app.example.com', data: { subtype: 'dns_query', query: 'app.example.com' } }, + { agent_type: 'shell', data: { subtype: 'command_start', command: 'whoami' } } + ]) await post(e) + + // Normalise the persisted axis to source so the suite is deterministic + // regardless of a prior session's toggle (localStorage survives across runs). + await page.evaluate(() => localStorage.setItem('redlog-timeline-lane-axis', 'source')) + await page.reload() + await page.waitForLoadState('domcontentloaded') + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+2' : 'Control+2') + await expect(page.locator('[data-testid="view-root"]')).toHaveAttribute('data-view', 'timeline') + await expect(shellLane()).toBeVisible({ timeout: 10_000 }) + }) + + test.afterAll(async () => { await app?.close() }) + + // Normalise the axis to source before each test, deterministically and without + // a reload: the app already sits on the timeline (beforeAll), and the mounted + // Timeline listens for this event, so dispatching 'source' resets a prior + // test's toggle without a racy reload/re-navigation. + test.beforeEach(async () => { + await page.evaluate(() => { + window.dispatchEvent(new CustomEvent('redlog-timeline-set-axis', { detail: 'source' })) + }) + await expect(shellLane()).toBeVisible({ timeout: 10_000 }) + }) + + test('source axis renders source lanes, no crash', async () => { + await expect(page.getByText('timeline crashed')).toHaveCount(0) + await expect(shellLane()).toBeVisible() + }) + + test('target axis renders target lanes + untargeted, no crash', async () => { + await page.getByRole('button').filter({ hasText: 'By source' }).first().click({ timeout: 5000 }) + await expect(page.getByText('timeline crashed')).toHaveCount(0) + await expect(page.getByText('10.0.0.5').first()).toBeVisible() + await expect(page.getByText('Untargeted').first()).toBeVisible() + }) + + test('toggling to target and back restores source lanes', async () => { + await page.getByRole('button').filter({ hasText: 'By source' }).first().click({ timeout: 5000 }) + await expect(page.getByText('10.0.0.5').first()).toBeVisible() + await page.getByRole('button').filter({ hasText: 'By target' }).first().click({ timeout: 5000 }) + await expect(page.getByText('timeline crashed')).toHaveCount(0) + await expect(shellLane()).toBeVisible() + }) + + test('phase ribbon toggles on without crashing', async () => { + // The scanner/dns events infer a "recon" phase, so the ribbon has a dashed + // band to render. Just guard that enabling it doesn't crash the track. + await page.getByRole('button').filter({ hasText: 'Phases' }).first().click({ timeout: 5000 }) + await expect(page.getByText('timeline crashed')).toHaveCount(0) + await expect(shellLane()).toBeVisible() + }) + + test('the Targets sidebar entry deep-links into the timeline target axis', async () => { + // Step 5 (O3): TargetView was removed; "Targets" now opens the timeline with + // the target axis on. beforeEach already put us on source. + await page.locator('nav').getByRole('button', { name: /Targets/ }).click() + // Success = the timeline is now on the target axis (target lanes present). + await expect(page.getByText('10.0.0.5').first()).toBeVisible() + await expect(page.getByText('Untargeted').first()).toBeVisible() + await expect(page.getByText('timeline crashed')).toHaveCount(0) + }) +}) diff --git a/electron-builder.yml b/electron-builder.yml index d908c4ae..e1a104a2 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -19,6 +19,10 @@ extraResources: # 🔴 plugin isolation runner — forked into a utilityProcess for trusted plugins - from: resources/plugin-runner.js to: plugin-runner.js + # Bundled plugin packs (scan-parsers, c2-tailers, …) — discovered from + # /plugins at runtime by loader.ts::bundledRoot(). + - from: plugins + to: plugins mac: icon: resources/icon.icns category: public.app-category.developer-tools diff --git a/hooks/mitmproxy-addon.py b/hooks/mitmproxy-addon.py index a5f7cfa1..624278be 100644 --- a/hooks/mitmproxy-addon.py +++ b/hooks/mitmproxy-addon.py @@ -28,7 +28,7 @@ - DNS response: response_code, answers, duration_ms, _causes ← query event Environment variables: - REDLOG_MAX_BODY Max request/response body bytes to capture (default 2048) + REDLOG_MAX_BODY Max request/response body bytes to capture (default 16384) REDLOG_SKIP_STATIC Skip static assets like .css/.js/.png/.woff (default true) REDLOG_VERBOSE Log every request/DNS message to stderr (default false) """ @@ -51,7 +51,17 @@ REDLOG_PORT_FILE = Path.home() / ".redlog" / "api-port" REDLOG_TOKEN_FILE = Path.home() / ".redlog" / "api-token" -MAX_BODY = int(os.environ.get("REDLOG_MAX_BODY", "2048")) +# 16 KB by default (was 2 KB) — enough to review a typical JSON/form request or +# response body without truncation. Bodies still live inline on the event (no +# sidecar yet), so this trades a little DB size for actually-reviewable payloads. +MAX_BODY = int(os.environ.get("REDLOG_MAX_BODY", "16384")) +# io_ref sidecar (docs/SPEC-IO-SIDECAR.md): when a body is larger than the +# inline preview cap, also post the FULL body (up to this ceiling) on a +# `*_body_full` field. The server hashes it, writes /io/.bin +# and keeps only the digest on the event — the full body stops being lost to +# truncation without bloating the chain. Bodies above this ceiling are captured +# up to it and flagged truncated. +MAX_IO = int(os.environ.get("REDLOG_MAX_IO", str(2 * 1024 * 1024))) SKIP_STATIC = os.environ.get("REDLOG_SKIP_STATIC", "true").lower() in ("true", "1", "yes") VERBOSE = os.environ.get("REDLOG_VERBOSE", "false").lower() in ("true", "1", "yes") @@ -187,6 +197,24 @@ def _truncate(s: str, max_len: int = MAX_BODY) -> str: return s[:max_len] + f"...[truncated, {len(s)} total]" +def _full_body_fields(text: str, content_type: str, prefix: str) -> dict: + """Fields to sidecar the full body — only when the inline preview truncated. + + A body that fits within MAX_BODY is already complete in its `*_preview`, so + it never sidecars (keeps the migration purely additive). A larger body is + posted whole up to MAX_IO; beyond that we send the first MAX_IO bytes and + flag it truncated so the record stays honest. + """ + if not text or len(text) <= MAX_BODY: + return {} + truncated = len(text) > MAX_IO + return { + f"{prefix}_body_full": text[:MAX_IO] if truncated else text, + f"{prefix}_body_ct": content_type or "", + f"{prefix}_body_full_truncated": truncated, + } + + def _extract_params(flow: http.HTTPFlow) -> dict: params = {} parsed = urlparse(flow.request.pretty_url) @@ -344,10 +372,12 @@ def request(self, flow: http.HTTPFlow): req_headers = _extract_interesting_headers(flow.request.headers, "request") request_body_preview = "" + req_body_fields = {} content_type = flow.request.headers.get("content-type", "") if flow.request.content and any(t in content_type for t in REQUEST_BODY_CONTENT_TYPES): req_text = flow.request.get_text(strict=False) or "" request_body_preview = _truncate(req_text, MAX_BODY) + req_body_fields = _full_body_fields(req_text, content_type, "request") event_data = { "subtype": "http_request_start", @@ -366,6 +396,7 @@ def request(self, flow: http.HTTPFlow): event_data["request_headers"] = req_headers if request_body_preview: event_data["request_body_preview"] = request_body_preview + event_data.update(req_body_fields) payload = { "agent_type": "scanner", @@ -400,11 +431,13 @@ def response(self, flow: http.HTTPFlow): resp_headers = _extract_interesting_headers(flow.response.headers, "response") if flow.response else {} response_body_preview = "" + resp_body_fields = {} if flow.response and flow.response.content: content_type = flow.response.headers.get("content-type", "") if any(t in content_type for t in ("json", "html", "text", "xml", "javascript")): resp_text = flow.response.get_text(strict=False) or "" response_body_preview = _truncate(resp_text, MAX_BODY) + resp_body_fields = _full_body_fields(resp_text, content_type, "response") content_length = 0 if flow.response and flow.response.content: @@ -427,6 +460,7 @@ def response(self, flow: http.HTTPFlow): event_data["response_headers"] = resp_headers if response_body_preview: event_data["response_preview"] = response_body_preview + event_data.update(resp_body_fields) if duration_ms is not None: event_data["duration_ms"] = duration_ms diff --git a/package.json b/package.json index f0d14f66..cd670cb1 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "pree2e": "electron-rebuild -f -o better-sqlite3", "e2e": "playwright test", "pree2e:ui": "electron-rebuild -f -o better-sqlite3", - "e2e:ui": "playwright test --ui" + "e2e:ui": "playwright test --ui", + "typecheck": "tsc --noEmit -p tsconfig.web.json" }, "dependencies": { "@xterm/addon-fit": "^0.10.0", diff --git a/plugins/c2-tailers/README.md b/plugins/c2-tailers/README.md new file mode 100644 index 00000000..7584275b --- /dev/null +++ b/plugins/c2-tailers/README.md @@ -0,0 +1,55 @@ +# c2-tailers + +Bundled RedLog pack (SPEC-AI-ERA-PLUGINS Gap 2). C2 beacons are an out-of-band +channel the substrate capture never sees — the check-ins, task results and pivots +live in the framework's own log. This follows that log and lands each line on the +RedLog timeline as a `scanner.c2_checkin` / `scanner.c2_task` or a `pivot` event. + +**Trust:** 🟢 declarative. The tailer runs shell-side (out-of-process) and POSTs +to RedLog's local authenticated API — no code runs inside RedLog. It's a +standalone follower rather than an in-RedLog `TailerAdapter` on purpose: the +built-in tailer system is transcript-shaped (agent turns / cwd) and trust-gated, +whereas a C2 log is a raw event stream that should emit scanner/pivot events. +Records only while RedLog is open. + +## Use + +```sh +# Sliver: follow a session/beacon JSON log (best-effort mapping of Sliver's fields) +plugins/c2-tailers/hooks/sliver-tail.sh ~/.sliver/logs/sliver.json + +# Any framework: emit the RedLog-C2 JSONL contract to a file and follow it +plugins/c2-tailers/hooks/generic-tail.sh /path/to/c2-events.jsonl +``` + +The follower polls for appends every second and restarts cleanly on truncation +or rotation. + +## RedLog-C2 JSONL contract (the `generic` framework) + +One JSON object per line. `kind` selects the mapping: + +```jsonc +{ "kind": "checkin", "framework": "mythic", "session": "b-01", + "host": "10.0.0.5", "os": "linux", "user": "root", "is_beacon": true } +{ "kind": "task", "session": "b-01", "host": "10.0.0.5", + "command": "whoami", "output_len": 12 } +{ "kind": "pivot", "framework": "sliver", "via": "10.0.0.5", + "route": "10.1.0.0/16" } // add "closed": true for teardown +``` + +- `checkin`/`session` → `scanner.c2_checkin` (target = host) +- `task` → `scanner.c2_task` +- `pivot` → a `pivot` event (`subtype: open|closed`), aligned with the built-in + pivot detector's shape. + +All fields are the framework's own record, recorded verbatim — not a RedLog +verdict (DESIGN-PRINCIPLES §3). Point any C2's logging/scripting at this contract +to get first-class timeline coverage without a bespoke integration. + +## Sliver mapping + +The `sliver` framework maps Sliver session/beacon objects +(`ID`/`Name`/`Hostname`/`Username`/`OS`/`RemoteAddress`/`IsBeacon`) to +`c2_checkin`. Exact log schemas vary by Sliver version, so unrecognized lines are +skipped rather than guessed — use the generic contract for full control. diff --git a/plugins/c2-tailers/c2-tail.js b/plugins/c2-tailers/c2-tail.js new file mode 100644 index 00000000..a7ca07c2 --- /dev/null +++ b/plugins/c2-tailers/c2-tail.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node +// c2-tail — follow a C2 framework log and POST each parsed line to RedLog +// (SPEC-AI-ERA-PLUGINS Gap 2). A shell-side continuous poster (🟢, out-of-process), +// not an in-RedLog TailerAdapter: the built-in tailer system is transcript-shaped +// (ParsedTurn/cwd) and trust-gated, whereas a C2 log is a raw event stream that +// should emit scanner/pivot events — so the faithful, safer vehicle is a +// standalone follower that talks to the authenticated local API, like scan-parsers. +// +// node c2-tail.js +// framework = generic | sliver (see parse.js for the shapes) +// +// Follows appends (poll every 1s), survives truncation/rotation (re-reads from 0 +// when the file shrinks), and records only while RedLog is open. +'use strict' + +const fs = require('fs') +const os = require('os') +const path = require('path') +const { parseC2Line } = require('./parse.js') + +const framework = process.argv[2] || 'generic' +const logFile = process.argv[3] +if (!logFile) { + process.stderr.write('usage: c2-tail.js \n') + process.exit(2) +} + +const portFile = path.join(os.homedir(), '.redlog', 'api-port') +const tokenFile = path.join(os.homedir(), '.redlog', 'api-token') + +function redlogConn() { + try { + if (!fs.existsSync(portFile) || !fs.existsSync(tokenFile)) return null + return { port: fs.readFileSync(portFile, 'utf8').trim(), token: fs.readFileSync(tokenFile, 'utf8').trim() } + } catch { return null } +} + +async function post(ev) { + const conn = redlogConn() + if (!conn) return false // RedLog closed → capture nothing + try { + const r = await fetch(`http://127.0.0.1:${conn.port}/api/events`, { + method: 'POST', + headers: { Authorization: `Bearer ${conn.token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(ev) + }) + return r.ok + } catch { return false } +} + +let offset = 0 +let carry = '' +let posted = 0 + +async function drain() { + let stat + try { stat = fs.statSync(logFile) } catch { return } // not created yet + if (stat.size < offset) { offset = 0; carry = '' } // truncated / rotated → restart + if (stat.size === offset) return + let chunk = '' + try { + const fd = fs.openSync(logFile, 'r') + const buf = Buffer.alloc(stat.size - offset) + fs.readSync(fd, buf, 0, buf.length, offset) + fs.closeSync(fd) + chunk = buf.toString('utf8') + } catch { return } + offset = stat.size + const lines = (carry + chunk).split('\n') + carry = lines.pop() || '' // last partial line waits for the rest + for (const line of lines) { + const ev = parseC2Line(line, framework) + if (ev && await post(ev)) { + posted++ + process.stderr.write(`[redlog c2-tailers] posted ${framework} event (${posted} total)\r`) + } + } +} + +process.stderr.write(`[redlog c2-tailers] following ${logFile} (${framework})\n`) +setInterval(() => { void drain() }, 1000) +void drain() diff --git a/plugins/c2-tailers/hooks/generic-tail.sh b/plugins/c2-tailers/hooks/generic-tail.sh new file mode 100644 index 00000000..0dfdf079 --- /dev/null +++ b/plugins/c2-tailers/hooks/generic-tail.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# c2-tailers: follow a generic RedLog-C2 JSONL log into RedLog's timeline. +# Usage: generic-tail.sh +here="$(cd "$(dirname "$0")/.." && pwd)" +exec node "$here/c2-tail.js" generic "${1:?path to jsonl log required}" diff --git a/plugins/c2-tailers/hooks/sliver-tail.sh b/plugins/c2-tailers/hooks/sliver-tail.sh new file mode 100644 index 00000000..a769c3fc --- /dev/null +++ b/plugins/c2-tailers/hooks/sliver-tail.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# c2-tailers: follow a Sliver session/beacon JSON log into RedLog's timeline. +# Usage: sliver-tail.sh +here="$(cd "$(dirname "$0")/.." && pwd)" +exec node "$here/c2-tail.js" sliver "${1:?path to sliver json log required}" diff --git a/plugins/c2-tailers/parse.js b/plugins/c2-tailers/parse.js new file mode 100644 index 00000000..e068a748 --- /dev/null +++ b/plugins/c2-tailers/parse.js @@ -0,0 +1,99 @@ +// c2-tailers — pure parsers for C2 framework logs (SPEC-AI-ERA-PLUGINS Gap 2). +// Dependency-free CommonJS, ships inside a bundled plugin and runs shell-side +// (out-of-process), so no imports from RedLog core. Unit-tested in +// test/c2-tailers.test.ts. +// +// C2 beacons are an out-of-band channel the substrate capture never sees — the +// check-ins, task results and pivots live in the framework's own log. This maps +// a followed log line into a RedLog event (scanner.c2_checkin / c2_task, or a +// `pivot` event), so the operator's C2 activity lands on the same timeline as +// everything else. Nothing here is a RedLog verdict — the fields are the +// framework's own record, recorded verbatim (DESIGN-PRINCIPLES §3). +// +// Two input shapes: +// - 'generic': a documented RedLog-C2 JSONL contract (the pack owns it, so it +// is stable + fully tested). Emit these from any framework's scripting. +// - 'sliver' : best-effort mapping of Sliver session/beacon JSON objects. + +'use strict' + +/** Generic RedLog-C2 line → event payload, or null to skip. + * Contract: { kind: 'checkin'|'task'|'pivot', session, host, ... }. */ +function fromGeneric(o) { + const host = o.host || o.remote_addr || '' + switch (o.kind) { + case 'checkin': + case 'session': + return { + agent_type: 'scanner', + target_id: host || undefined, + data: { + subtype: 'c2_checkin', framework: o.framework || 'generic', + session: o.session || o.id || '', host, detectedTarget: host || undefined, + os: o.os || undefined, user: o.user || undefined, + remote_addr: o.remote_addr || undefined, is_beacon: o.is_beacon === true || undefined + } + } + case 'task': + return { + agent_type: 'scanner', + target_id: host || undefined, + data: { + subtype: 'c2_task', framework: o.framework || 'generic', + session: o.session || '', host, detectedTarget: host || undefined, + command: o.command || '', output_len: typeof o.output_len === 'number' ? o.output_len : undefined + } + } + case 'pivot': + return { + agent_type: 'pivot', + target_id: (o.via || host) || undefined, + data: { + subtype: o.closed ? 'closed' : 'open', tool: o.framework || 'c2', + via: o.via || host || undefined, route: o.route || undefined, + description: `C2 pivot via ${o.framework || 'c2'}${o.via ? ` → ${o.via}` : ''}` + } + } + default: + return null + } +} + +/** Best-effort Sliver session/beacon object → event payload. Sliver session + * objects carry ID/Name/Hostname/Username/OS/RemoteAddress/IsBeacon. */ +function fromSliver(o) { + const host = o.Hostname || o.RemoteAddress || '' + if (!o.ID && !o.Name && !host) return null + return { + agent_type: 'scanner', + target_id: host || undefined, + data: { + subtype: 'c2_checkin', framework: 'sliver', + session: o.Name || o.ID || '', host, detectedTarget: host || undefined, + os: o.OS || undefined, user: o.Username || undefined, + remote_addr: o.RemoteAddress || undefined, is_beacon: o.IsBeacon === true || undefined + } + } +} + +/** Parse one log line for a framework. Returns an event payload or null. */ +function parseC2Line(line, framework) { + const s = String(line || '').trim() + if (!s) return null + let o + try { o = JSON.parse(s) } catch { return null } + if (!o || typeof o !== 'object') return null + return framework === 'sliver' ? fromSliver(o) : fromGeneric(o) +} + +/** Parse a whole log blob (JSONL) → event payloads, skipping junk lines. */ +function parseC2Log(text, framework) { + const out = [] + for (const line of String(text || '').split('\n')) { + const ev = parseC2Line(line, framework) + if (ev) out.push(ev) + } + return out +} + +module.exports = { parseC2Line, parseC2Log, fromGeneric, fromSliver } diff --git a/plugins/c2-tailers/plugin.json b/plugins/c2-tailers/plugin.json new file mode 100644 index 00000000..1623c0c6 --- /dev/null +++ b/plugins/c2-tailers/plugin.json @@ -0,0 +1,36 @@ +{ + "id": "c2-tailers", + "name": "C2 Log Tailers", + "version": "1.0.0", + "description": "Bundled pack (SPEC-AI-ERA-PLUGINS Gap 2): follow a C2 framework log (Sliver, or any framework via the generic JSONL contract) and land beacon check-ins, task results and pivots on the RedLog timeline. Runs shell-side (out-of-process) and POSTs to the local API — no code runs inside RedLog.", + "author": "RedLog", + "redlogApi": 1, + "contributes": { + "capture": [ + { + "id": "sliver-log-tail", + "name": "Sliver → c2_checkin", + "description": "Follow a Sliver session/beacon JSON log; each object becomes a scanner.c2_checkin event.", + "agentType": "scanner", + "requires": ["node"], + "hookFile": "hooks/sliver-tail.sh", + "installMethod": "manual", + "manualSteps": [ + { "label": "Point the tailer at your Sliver JSON log (adjust the path)", "command": "\"{hookFile}\" ~/.sliver/logs/sliver.json" } + ] + }, + { + "id": "generic-c2-tail", + "name": "Generic C2 → timeline", + "description": "Follow any C2 log emitted in the RedLog-C2 JSONL contract ({kind:'checkin'|'task'|'pivot', ...}); see README.", + "agentType": "scanner", + "requires": ["node"], + "hookFile": "hooks/generic-tail.sh", + "installMethod": "manual", + "manualSteps": [ + { "label": "Have your framework append RedLog-C2 JSONL to a file, then follow it", "command": "\"{hookFile}\" /path/to/c2-events.jsonl" } + ] + } + ] + } +} diff --git a/plugins/scan-parsers/README.md b/plugins/scan-parsers/README.md new file mode 100644 index 00000000..a4491e01 --- /dev/null +++ b/plugins/scan-parsers/README.md @@ -0,0 +1,41 @@ +# scan-parsers + +Bundled RedLog pack (SPEC-AI-ERA-PLUGINS Gap 1). Turns structured scanner output +into typed `scanner.scan_result` timeline events, so a scan is more than a raw +stdout blob — each host/port (nmap) or finding (nuclei) becomes a citable event +grouped under its target. + +**Trust:** 🟢 declarative. The parser runs in *your* shell as a pipe stage and +POSTs to RedLog's local authenticated API — no third-party code runs inside +RedLog, nothing can touch the hash chain. Records only while RedLog is open. + +## Use + +```sh +# nmap: the wrapper echoes nmap's output and captures each host +plugins/scan-parsers/hooks/nmap-redlog.sh -sV 10.0.0.0/24 + +# nuclei: same idea, one scan_result per finding (severity as nuclei reported it) +plugins/scan-parsers/hooks/nuclei-redlog.sh -u https://target.example.com + +# or parse an existing file +cat scan.gnmap | node plugins/scan-parsers/scan-to-redlog.js nmap +cat out.jsonl | node plugins/scan-parsers/scan-to-redlog.js nuclei +``` + +`nmap` must be run with greppable output (`-oG -`, the wrappers add it); `nuclei` +with `-jsonl`. Both are transparent filters — you still see the tool's output. + +## What it records + +- `agent_type: scanner`, `subtype: scan_result`, `target_id: ` +- nmap: `host`, `hostname`, `ports[]` (open only), `open_port_count`, `summary` +- nuclei: `template_id`, `name`, `severity` (verbatim from the tool), `matched_at` + +Severity is recorded as the tool reported it — a fact about the tool output, not +a RedLog verdict (DESIGN-PRINCIPLES §3). + +## Non-goals + +Doesn't parse the raw scan file into the chain — only the normalized events land +there. Point it at the structured stream; keep your `-oN`/`-oX` files as usual. diff --git a/plugins/scan-parsers/hooks/nmap-redlog.sh b/plugins/scan-parsers/hooks/nmap-redlog.sh new file mode 100755 index 00000000..1d186960 --- /dev/null +++ b/plugins/scan-parsers/hooks/nmap-redlog.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# scan-parsers: run nmap and capture its results into RedLog's timeline. +# Greppable output (-oG -) is piped through the parser, which echoes it back so +# you still see it. Your other -o flags (-oN/-oX to files) are unaffected. +# Usage: nmap-redlog.sh e.g. nmap-redlog.sh -sV 10.0.0.0/24 +here="$(cd "$(dirname "$0")/.." && pwd)" +exec nmap -oG - "$@" | node "$here/scan-to-redlog.js" nmap diff --git a/plugins/scan-parsers/hooks/nuclei-redlog.sh b/plugins/scan-parsers/hooks/nuclei-redlog.sh new file mode 100755 index 00000000..0c9e8448 --- /dev/null +++ b/plugins/scan-parsers/hooks/nuclei-redlog.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# scan-parsers: run nuclei and capture each finding into RedLog's timeline. +# JSONL output is piped through the parser, which echoes it back so you still +# see the stream. +# Usage: nuclei-redlog.sh e.g. nuclei-redlog.sh -u https://x.example.com +here="$(cd "$(dirname "$0")/.." && pwd)" +exec nuclei -jsonl "$@" | node "$here/scan-to-redlog.js" nuclei diff --git a/plugins/scan-parsers/parse.js b/plugins/scan-parsers/parse.js new file mode 100644 index 00000000..4a1c1d4a --- /dev/null +++ b/plugins/scan-parsers/parse.js @@ -0,0 +1,119 @@ +// scan-parsers — pure parsers for structured scanner output (SPEC-AI-ERA-PLUGINS +// Gap 1). Self-contained CommonJS, ZERO dependencies: this module ships inside a +// bundled plugin and runs in the operator's shell (out-of-process), so it can't +// import from RedLog's src/core. Kept pure + unit-tested (test/scan-parsers.test.ts). +// +// Each parser turns a tool's structured output into normalized `scan_result` +// events. The captured facts (host, port, service, template id, the tool's own +// severity label) are recorded verbatim; RedLog adds no interpretation of its +// own here — severity is "what the tool reported", not a RedLog verdict. + +'use strict' + +/** Parse nmap greppable output (`nmap -oG -`). One record per host, with its + * open ports. Malformed / comment lines are skipped, never thrown on. */ +function parseNmapGreppable(text) { + const hosts = [] + for (const line of String(text || '').split('\n')) { + if (!line.startsWith('Host: ')) continue + // Host: 10.0.0.5 (name)\tPorts: 22/open/tcp//ssh//OpenSSH//, 80/open/tcp//http//nginx//\tStatus: Up + const hostMatch = line.match(/^Host:\s+(\S+)\s+\(([^)]*)\)/) + if (!hostMatch) continue + const host = hostMatch[1] + const hostname = hostMatch[2] || '' + const portsSeg = line.split(/\tPorts:\s*/)[1] + const ports = [] + if (portsSeg) { + for (const spec of portsSeg.split(',')) { + // port/state/proto/owner/service/rpc/version/ + const parts = spec.trim().split('/') + if (parts.length < 3) continue + const portNum = parseInt(parts[0], 10) + if (!Number.isFinite(portNum)) continue + const state = parts[1] || '' + if (state !== 'open') continue // only surface open ports + ports.push({ + port: portNum, + proto: parts[2] || '', + service: parts[4] || '', + version: (parts[6] || '').trim() + }) + } + } + hosts.push({ host, hostname, ports }) + } + return hosts +} + +/** Parse nuclei JSON-lines output (`nuclei -jsonl` / `-json`). One record per + * finding. Skips blank / unparseable lines. */ +function parseNucleiJsonl(text) { + const out = [] + for (const line of String(text || '').split('\n')) { + const s = line.trim() + if (!s) continue + let obj + try { obj = JSON.parse(s) } catch { continue } + if (!obj || typeof obj !== 'object') continue + const info = obj.info || {} + out.push({ + templateId: obj['template-id'] || obj.templateID || '', + name: info.name || '', + severity: info.severity || 'unknown', // the tool's own label, recorded verbatim + host: obj.host || '', + matchedAt: obj['matched-at'] || obj.matched || '', + type: obj.type || '' + }) + } + return out +} + +/** Normalize parsed records into RedLog event payloads (POST /api/events shape). + * `agent_type: 'scanner'`, `subtype: 'scan_result'`; `target_id` is the host so + * the timeline's target axis groups them. One event per host (nmap) / finding + * (nuclei). */ +function toScanEvents(tool, parsed) { + if (tool === 'nmap') { + return parsed.map((h) => ({ + agent_type: 'scanner', + target_id: h.host, + data: { + subtype: 'scan_result', + tool: 'nmap', + host: h.host, + hostname: h.hostname || undefined, + detectedTarget: h.host, + ports: h.ports, + open_port_count: h.ports.length, + summary: h.ports.map((p) => `${p.port}/${p.proto} ${p.service}`.trim()).join(', ') + } + })) + } + if (tool === 'nuclei') { + return parsed.map((f) => ({ + agent_type: 'scanner', + target_id: f.host, + data: { + subtype: 'scan_result', + tool: 'nuclei', + host: f.host, + detectedTarget: f.host, + template_id: f.templateId, + name: f.name, + severity: f.severity, // nuclei's own severity — a fact about the tool output + matched_at: f.matchedAt, + result_type: f.type + } + })) + } + return [] +} + +/** One-shot: raw tool output → event payloads. */ +function parse(tool, text) { + if (tool === 'nmap') return toScanEvents('nmap', parseNmapGreppable(text)) + if (tool === 'nuclei') return toScanEvents('nuclei', parseNucleiJsonl(text)) + return [] +} + +module.exports = { parseNmapGreppable, parseNucleiJsonl, toScanEvents, parse } diff --git a/plugins/scan-parsers/plugin.json b/plugins/scan-parsers/plugin.json new file mode 100644 index 00000000..de4b2936 --- /dev/null +++ b/plugins/scan-parsers/plugin.json @@ -0,0 +1,51 @@ +{ + "id": "scan-parsers", + "name": "Scan Parsers", + "version": "1.0.0", + "description": "Bundled pack (SPEC-AI-ERA-PLUGINS Gap 1): parse structured scanner output (nmap greppable, nuclei JSONL) into typed scanner.scan_result timeline events. Runs no code inside RedLog — the parser is a shell-side pipe stage that POSTs to the local API.", + "author": "RedLog", + "redlogApi": 1, + "contributes": { + "targetExtractors": [ + { + "cmd": "^nuclei\\s", + "extract": "-u\\s+https?://([^/\\s]+)", + "name": "nuclei-target", + "description": "Extracts the host from a `nuclei -u ` invocation" + }, + { + "cmd": "^(nmap|masscan)\\s", + "extract": "(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(?:/\\d{1,2})?)", + "name": "nmap-target-ip", + "description": "Extracts an IPv4 / CIDR target from an nmap or masscan command" + } + ], + "capture": [ + { + "id": "nmap-scan-parser", + "name": "nmap → scan_result", + "description": "Pipe nmap greppable output through the parser so each host+ports lands as a scanner.scan_result event.", + "agentType": "scanner", + "requires": ["nmap", "node"], + "hookFile": "hooks/nmap-redlog.sh", + "installMethod": "manual", + "manualSteps": [ + { "label": "Run your scan through the wrapper (it echoes nmap's output and captures it)", "command": "\"{hookFile}\" -sV 10.0.0.0/24" }, + { "label": "Or pipe an existing greppable scan file", "command": "cat scan.gnmap | node \"{pluginDir}/scan-to-redlog.js\" nmap" } + ] + }, + { + "id": "nuclei-scan-parser", + "name": "nuclei → scan_result", + "description": "Pipe nuclei JSONL output through the parser so each finding lands as a scanner.scan_result event (severity recorded as the tool reported it).", + "agentType": "scanner", + "requires": ["nuclei", "node"], + "hookFile": "hooks/nuclei-redlog.sh", + "installMethod": "manual", + "manualSteps": [ + { "label": "Run nuclei through the wrapper", "command": "\"{hookFile}\" -u https://target.example.com" } + ] + } + ] + } +} diff --git a/plugins/scan-parsers/scan-to-redlog.js b/plugins/scan-parsers/scan-to-redlog.js new file mode 100755 index 00000000..e703ff26 --- /dev/null +++ b/plugins/scan-parsers/scan-to-redlog.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node +// scan-to-redlog — a transparent filter for the scan-parsers pack +// (SPEC-AI-ERA-PLUGINS Gap 1). Reads a scanner's structured output on stdin, +// echoes it back on stdout unchanged (so it stays a drop-in pipe stage), and +// POSTs the parsed `scan_result` events to RedLog's local API. Records nothing +// when RedLog isn't running (no api-port/token) — same "only while open" rule +// as every other capture hook. Zero dependencies beyond Node's stdlib + parse.js. +// +// nmap -oG - | node scan-to-redlog.js nmap +// nuclei -jsonl | node scan-to-redlog.js nuclei +'use strict' + +const fs = require('fs') +const os = require('os') +const path = require('path') +const { parse } = require('./parse.js') + +const tool = process.argv[2] || '' + +let input = '' +process.stdin.setEncoding('utf8') +process.stdin.on('data', (c) => { input += c; process.stdout.write(c) }) // pass-through +process.stdin.on('end', async () => { + let events = [] + try { events = parse(tool, input) } catch { events = [] } + if (events.length === 0) return + + const portFile = path.join(os.homedir(), '.redlog', 'api-port') + const tokenFile = path.join(os.homedir(), '.redlog', 'api-token') + if (!fs.existsSync(portFile) || !fs.existsSync(tokenFile)) return // RedLog not open — capture nothing + let port, token + try { + port = fs.readFileSync(portFile, 'utf8').trim() + token = fs.readFileSync(tokenFile, 'utf8').trim() + } catch { return } + + let posted = 0 + for (const ev of events) { + try { + const r = await fetch(`http://127.0.0.1:${port}/api/events`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(ev) + }) + if (r.ok) posted++ + } catch { /* RedLog closed mid-scan — drop silently */ } + } + process.stderr.write(`[redlog scan-parsers] posted ${posted}/${events.length} ${tool} scan_result event(s)\n`) +}) diff --git a/src/core/api-server.ts b/src/core/api-server.ts index 7b613f2f..e77fb544 100644 --- a/src/core/api-server.ts +++ b/src/core/api-server.ts @@ -37,6 +37,7 @@ import { getCaptureHealth } from './capture-health' import { resolveIncomingCauses, noteStartEvent } from './causes-resolver' import { isInsideDir } from './paths' import { getProjectDir } from './db/index' +import { stampIoRefs, readBody as readIoBody, resolveRef as resolveIoRef, MAX_IO_READ_BYTES } from './io-store' let appVersion = 'dev' export function setAppVersion(v: string): void { appVersion = v } @@ -510,6 +511,20 @@ async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse } } + // io_ref sidecar (SPEC-IO-SIDECAR.md): move any posted full HTTP body into + // /io/.bin and replace it with a digest-only ref BEFORE + // chaining, so the chain closes over the sha256, never the bytes (the + // v0.6.47 invariant). No-op for events without a `*_body_full` field. + try { + stampIoRefs(data, getProjectDir()) + } catch (e) { + // No active project dir (or write failed): never let raw bytes fall + // through into the chain — drop the full-body fields, keep the preview. + for (const f of ['request_body_full', 'request_body_ct', 'request_body_full_truncated', + 'response_body_full', 'response_body_ct', 'response_body_full_truncated']) delete data[f] + console.warn(`[api] io sidecar skipped: ${(e as Error).message}`) + } + const event = insertEvent(agentType, data, { engagementId, operatorId: operator.id, diff --git a/src/core/artifact-gc.ts b/src/core/artifact-gc.ts new file mode 100644 index 00000000..4635ddd8 --- /dev/null +++ b/src/core/artifact-gc.ts @@ -0,0 +1,90 @@ +// Artifact rotation planner (SPEC-SCOPE-AWARE-LIFECYCLE.md Part C). Pure: given +// the current io/ bodies and their resolved facts, decide what to WARM +// (compress) and what to PRUNE (delete) this sweep. The fs side effects live in +// retention.ts; the *decision* lives here so it is unit-testable in isolation. +// +// Three properties this function must get right (the restic/borg/IPFS lessons): +// 1. Refcount-gated: a content-addressed, deduped body is prunable only when +// EVERY referencing event is past its window. The caller encodes this by +// passing `ageDays` = the age of the NEWEST referencing event, so +// `ageDays > pruneDays` ⇒ even the most recent use is past ⇒ all are. +// 2. Age OR size: prune when past the age window, OR when the store is over +// its size cap — whichever hits first (logrotate's daily-vs-size). +// 3. Scope as pin: under size pressure, evict UNPINNED bodies first, ordered +// by pin score then age. Pinned (in-scope / marker-or-loot / operator) +// bodies are never size-evicted — only age-pruned once past their window. + +export interface ArtifactBody { + sha: string + /** On-disk size now (compressed size if already warm). */ + bytes: number + /** Already warm (compressed)? Warm bodies are not re-compressed. */ + compressed: boolean + /** Age of the NEWEST referencing event, in days (refcount gate). An orphan + * body with no referencing event uses its file age. */ + ageDays: number + /** Effective prune window for THIS body, in days. `0` = never age-prune. The + * caller derives it from scope: out-of-scope gets the short window; in-scope + * and `unknown` get the long one (never expire unclassified early, A3). */ + pruneDays: number + /** Pinned bodies are never size-evicted (isPinned over referencing events). */ + pinned: boolean + /** Eviction order key for unpinned bodies (lower = evicted first). */ + pinScore: number +} + +export interface RotationConfig { + /** Compress bodies older than this many days. `0` = never compress. */ + warmDays: number + /** Size cap for the whole io/ store, in bytes. `0` = no cap. */ + maxBytes: number +} + +export interface RotationPlan { + /** shas to warm-compress this sweep. */ + toCompress: string[] + /** shas to delete this sweep (age-past-window and/or size-evicted). */ + toPrune: string[] +} + +export function planArtifactRotation(bodies: ArtifactBody[], cfg: RotationConfig): RotationPlan { + const pruneSet = new Set() + + // 1. Age prune — past this body's own window (refcount-gated via ageDays). + for (const b of bodies) { + if (b.pruneDays > 0 && b.ageDays > b.pruneDays) pruneSet.add(b.sha) + } + + const survivors = bodies.filter((b) => !pruneSet.has(b.sha)) + + // 2. Warm compress — survivors that are old enough and not already warm. + const toCompress = new Set() + if (cfg.warmDays > 0) { + for (const b of survivors) { + if (!b.compressed && b.ageDays > cfg.warmDays) toCompress.add(b.sha) + } + } + + // 3. Size prune — if still over cap, evict UNPINNED survivors first, ordered + // by ascending pin score then descending age (least valuable + oldest go + // first). Pinned bodies are never size-evicted; in-scope evidence is kept + // longest by construction. Compression (step 2) shrinks the store on the + // NEXT sweep — we prune on current bytes here to stay conservative (never + // assume unrealized savings and under-reclaim). + if (cfg.maxBytes > 0) { + let total = survivors.reduce((s, b) => s + b.bytes, 0) + if (total > cfg.maxBytes) { + const evictable = survivors + .filter((b) => !b.pinned) + .sort((a, b) => (a.pinScore - b.pinScore) || (b.ageDays - a.ageDays)) + for (const b of evictable) { + if (total <= cfg.maxBytes) break + pruneSet.add(b.sha) + toCompress.delete(b.sha) // don't compress something we're deleting + total -= b.bytes + } + } + } + + return { toCompress: [...toCompress], toPrune: [...pruneSet] } +} diff --git a/src/core/artifact-pin.ts b/src/core/artifact-pin.ts new file mode 100644 index 00000000..1db5e113 --- /dev/null +++ b/src/core/artifact-pin.ts @@ -0,0 +1,49 @@ +// Pin score for artifact rotation (SPEC-SCOPE-AWARE-LIFECYCLE.md — "scope as +// pin", the IPFS lesson). Under size pressure the io/ store GCs UNPINNED bodies +// first and keeps pinned ones longest, so scope attaches to *rotation priority*, +// never to capture. This is the single pure function that decides eviction order. +// +// A body is content-addressed and deduped, so its pin inputs are the UNION over +// every event that references it: pinned if ANY referencing event pins it. + +export type ScopeVerdict = 'in_scope' | 'out_of_scope' | 'excluded' | 'unknown' + +export interface PinInputs { + /** Strongest scope verdict across referencing events (in_scope wins). */ + scope: ScopeVerdict + /** Any referencing event is a `loot` or `marker` — operator-asserted value. */ + referencedByMarkerOrLoot: boolean + /** An operator explicitly pinned this body. */ + operatorPinned: boolean +} + +// Higher = more pinned = evicted later. The tiers are ordinal, not additive — +// the strongest applicable reason wins, so an out-of-scope body that a marker +// cites is still pinned (evidence beats scope). +export const PIN_TIER = { + operator: 100, // explicit operator pin — never auto-evict + markerLoot: 80, // cited by a finding/loot event — evidence + inScope: 60, // in the engagement's scope + unknown: 30, // unclassified — evict before scoped-out, keep before excluded + outOfScope: 10, // outside scope + excluded: 0 // explicitly excluded — evict first +} as const + +/** Everything at or above this score is PINNED: never size-evicted, pruned only + * once past its age window. Below it is unpinned: first to go under pressure. */ +export const PIN_THRESHOLD = PIN_TIER.inScope + +export function pinScore(inp: PinInputs): number { + if (inp.operatorPinned) return PIN_TIER.operator + if (inp.referencedByMarkerOrLoot) return PIN_TIER.markerLoot + switch (inp.scope) { + case 'in_scope': return PIN_TIER.inScope + case 'unknown': return PIN_TIER.unknown + case 'out_of_scope': return PIN_TIER.outOfScope + case 'excluded': return PIN_TIER.excluded + } +} + +export function isPinned(inp: PinInputs): boolean { + return pinScore(inp) >= PIN_THRESHOLD +} diff --git a/src/core/authority.ts b/src/core/authority.ts new file mode 100644 index 00000000..724bc96e --- /dev/null +++ b/src/core/authority.ts @@ -0,0 +1,85 @@ +// The §3 authority primitive (DECOMPOSITION-BACKLOG K1, minimal slice). +// +// DESIGN-PRINCIPLES §3 draws one line: RedLog records FACTS and treats every +// interpretation as a SUGGESTION. Until now that line was enforced by each +// author remembering it, and it had drifted into three unrelated spellings — +// `phaseInference.ts`'s renderer-only `Confidence`, `loot-detector.ts`'s +// per-match `confidence`, and `scope-monitor.ts`'s per-event `authority`. This +// module is the one answer to "is this event an observation or a judgement?", +// so the honesty stops depending on anyone remembering. +// +// AUTHORITY IS NOT CONFIDENCE. They are orthogonal and must not be merged: +// +// * `authority` — observed or inferred. Two values. Decides RENDERING +// (solid vs dashed) and FORWARDING (deconfliction's authority floor). +// * `confidence` — how strong an inference is. Only meaningful when authority +// is `inferred`; a fact has no confidence to report. +// +// A scope violation on an explicitly excluded target is `fact` with no +// confidence; a loot match is `inferred` with `high`/`medium`/`low`. Collapsing +// them would force one of those two cases to lie. + +export type Authority = 'fact' | 'inferred' + +/** Default authority per built-in agent_type, from `EVENT-TYPE-VOCABULARY.md`'s + * origin classification. Only the detector-derived origins are listed — every + * other origin (operator-authored, primary capture, agent tool-calls, system + * drift signals) records something that actually happened, so `fact` is both + * the correct answer and the right default for anything unlisted. + * + * Note `scope_violation` is deliberately ABSENT even though the vocabulary doc + * files it under detector-derived: it is a `system` subtype, and it is `fact` + * when an excluded target matched but `inferred` when the match was proximity. + * Type-level authority cannot express that — which is precisely why the + * per-event override below takes precedence. */ +const BUILT_IN_AUTHORITY: Readonly> = { + loot: 'inferred', + pivot: 'inferred', + cleanup: 'inferred', + file_transfer: 'inferred' +} + +function isAuthority(v: unknown): v is Authority { + return v === 'fact' || v === 'inferred' +} + +interface EventLike { + agentType?: string + data?: Record | null +} + +/** Resolve an event's authority, most specific answer first: + * + * 1. `data.authority` — the emitter classified THIS event. Required whenever a + * type can produce both (see `scope_violation` above). + * 2. a registered `EventTypeDef.authority` — a plugin declared it for the type. + * 3. the built-in table. + * 4. `fact`. + * + * Why `fact` is the fallback and not `inferred`: primary capture dominates the + * event stream, so defaulting to `inferred` would dash almost everything and + * drain the distinction of meaning. The safety net for a detector is step 1 — + * emit the label with the event — not a pessimistic default. */ +export function authorityOf( + event: EventLike, + registered?: (agentType: string) => Authority | undefined +): Authority { + const perEvent = event.data?.authority + if (isAuthority(perEvent)) return perEvent + + const agentType = event.agentType ?? '' + if (registered) { + const declared = registered(agentType) + if (isAuthority(declared)) return declared + } + return BUILT_IN_AUTHORITY[agentType] ?? 'fact' +} + +/** Convenience for renderers: inferred elements must never present at full + * confidence — dashed outline, not a solid fill (§3). */ +export function isInferred( + event: EventLike, + registered?: (agentType: string) => Authority | undefined +): boolean { + return authorityOf(event, registered) === 'inferred' +} diff --git a/src/core/bundle-export.ts b/src/core/bundle-export.ts index 51fcc9ed..7e48092a 100644 --- a/src/core/bundle-export.ts +++ b/src/core/bundle-export.ts @@ -8,6 +8,9 @@ import { listQuickMarks } from './db/findings' import { listAnchors, computeChainHead } from './chain-anchor' import { listOperators, getPrimaryOperator, getPrimaryOperatorTokenHash } from './db/operators' import { getSanitizedFields, countSanitizedEvents } from './sanitize' +import { getSanitizedIo, runScopeSanitize } from './scope-sanitize' +import { buildAdherenceReport, summariseAdherence, type ScopeConfigSnapshot } from './scope-adherence' +import { classifyTarget } from './scope-monitor' interface ManifestFile { path: string @@ -32,6 +35,17 @@ interface ManifestPayload { * captured bytes (four-layer redaction, layer 4). Every one has a paired * system.sanitized event in events.jsonl proving the swap was audited. */ sanitized: { events: number; totalInDb: number } + /** G-D1: the POSITIVE proof, in the manifest so a reviewer reading only this + * file sees the claim. The evidence for it is `scope-adherence.json`, which + * is itself in `files` and therefore under the manifest hash + HMAC. Null + * when no scope was configured — there is nothing to claim. */ + scopeAdherence: { + summary: string + totals: Record + scopeChanges: number + disagreements: number + provenance: { path: string; digest: string; entries: number } | null + } | null files: ManifestFile[] } @@ -59,6 +73,22 @@ export interface ExportBundleOpts { * is the safe default, and this flag is the opt-in for engagements that * legitimately need the pre-redaction content. */ includeAgentTranscripts?: boolean + /** Export profile (SPEC-SCOPE-AWARE-LIFECYCLE.md §9 progressive disclosure). + * `internal` (default) exports full content. `client-deliverable` runs the + * scope planner first — out-of-scope / excluded event bodies AND their io + * sidecar bodies are sanitized (the scope_violation + touched host remain), + * `unknown` targets are left for operator decision. Requires `scope` + + * `operatorId` so the sanitize can attribute + classify. */ + profile?: 'internal' | 'client-deliverable' + /** Scope config. Required by the `client-deliverable` profile's sanitize + * pass; also drives the adherence report (G-D1) in EVERY profile when + * present. The extra fields are optional so the sanitize caller is + * unaffected — they only sharpen the classification. */ + scope?: ScopeConfigSnapshot + /** Operator id that owns the sanitize pass (client-deliverable profile). */ + operatorId?: string + /** Also sanitize `unknown`-target events (operator opt-in, default false). */ + sanitizeUnknown?: boolean } export function exportBundle(engagementId: string, outRootOrOpts?: string | ExportBundleOpts): EvidenceBundle { @@ -71,6 +101,21 @@ export function exportBundle(engagementId: string, outRootOrOpts?: string | Expo const bundleDir = path.join(outRoot ?? path.join(projectDir, 'exports'), `bundle-${ts}`) fs.mkdirSync(bundleDir, { recursive: true }) + // client-deliverable profile: run the scope sanitize pass BEFORE packaging so + // the events.jsonl + io swaps below serve redacted out-of-scope content. The + // pass appends a chained system.sanitized (tamper-evident) and writes to the + // sanitized_events / sanitized_io side tables the export already reads. + if (opts.profile === 'client-deliverable' && opts.scope && opts.operatorId) { + const candidates = queryEvents({ limit: 1_000_000 }).map((e) => ({ id: e.id, targetId: e.targetId, data: e.data })) + runScopeSanitize({ + events: candidates, + classify: (t) => classifyTarget(t, opts.scope!), + operatorId: opts.operatorId, + engagementId, + includeUnknown: opts.sanitizeUnknown === true + }) + } + const files: ManifestFile[] = [] // 1. events.jsonl (in insertion order) @@ -90,6 +135,16 @@ export function exportBundle(engagementId: string, outRootOrOpts?: string | Expo // the original bytes; auditors verifying the chain need the source DB or // the paired system.sanitized event to reconcile. let sanitizedRowsWritten = 0 + // G-D1: the adherence report is built from the rows AS WRITTEN TO THE BUNDLE, + // after the layer-4 swap — never from the raw DB. That is the invariant that + // keeps a `client-deliverable` bundle honest: the report's command samples + // can only ever contain what the bundle itself already contains, so it cannot + // become a side channel around the sanitize gate. Do not move this above the + // swap. (The scope planner leaves `detectedTarget` in place — the host stays, + // the exchange is what gets stripped — so the classification still works.) + const adherenceRows: Array<{ + timestamp: number; agentType: string; targetId?: string | null; data?: Record | null + }> = [] for (const row of rowIter) { const eventId = row.id as string const replacements = getSanitizedFields(eventId) @@ -102,6 +157,24 @@ export function exportBundle(engagementId: string, outRootOrOpts?: string | Expo } catch { /* leave row as-is if data isn't parseable */ } } fs.writeSync(fd, JSON.stringify(row) + '\n') + + // Cheap pre-filter: parsing every row of a 100k-event engagement to find + // the few that name a target is the wrong trade. A substring test on the + // raw JSON decides whether the parse is worth it; `target_id` is a column, + // so rows carrying only that are caught without any parse at all. + const rawData = String(row.data ?? '') + const mayMatter = rawData.includes('"detectedTarget"') + || rawData.includes('"scope_violation"') + || rawData.includes('"config_changed"') + if (!mayMatter && !row.target_id) continue + let parsed: Record | null = null + if (mayMatter) { try { parsed = JSON.parse(rawData) } catch { parsed = null } } + adherenceRows.push({ + timestamp: Number(row.timestamp), + agentType: String(row.agent_type), + targetId: (row.target_id as string | null) ?? null, + data: parsed + }) } fs.closeSync(fd) files.push({ path: 'events.jsonl', ...sha256File(eventsPath) }) @@ -164,6 +237,45 @@ export function exportBundle(engagementId: string, outRootOrOpts?: string | Expo } } + // 6a. io/ — copy every sidecar body (io_ref, SPEC-IO-SIDECAR.md). The events + // carry only the sha256; the full HTTP request/response bodies live here, so + // a bundle without them would verify but be unreadable. Each file is + // content-addressed (its name IS its sha256), and hashed into the manifest + // like casts/screenshots so an auditor can confirm the bytes match the digest + // the chain attests. A retention-pruned body simply isn't present — the + // manifest omission is the honest record, same as a pruned cast. + const srcIo = path.join(projectDir, 'io') + const dstIo = path.join(bundleDir, 'io') + if (fs.existsSync(srcIo)) { + // Layer-4 scope sanitize (SPEC-SCOPE-AWARE-LIFECYCLE.md Part B): a body with + // a sanitized replacement is served REDACTED under its original digest name; + // verify reads the system.sanitized io swap and confirms the bytes hash to + // the replacement digest → sanitized, not tampered. The source store is + // untouched. One replacement per digest (io bodies are deduped). + const sanitizedIo = getSanitizedIo() + const writtenSanitized = new Set() + fs.mkdirSync(dstIo, { recursive: true }) + for (const name of fs.readdirSync(srcIo)) { + const s = path.join(srcIo, name) + if (!(name.endsWith('.bin') || name.endsWith('.bin.gz')) || !fs.statSync(s).isFile()) continue + const stem = name.replace(/\.bin(\.gz)?$/, '') + const swap = sanitizedIo.get(stem) + if (swap) { + // Write the redacted replacement once, as an uncompressed `.bin`. + if (writtenSanitized.has(stem)) continue + writtenSanitized.add(stem) + const outName = `${stem}.bin` + const d = path.join(dstIo, outName) + fs.writeFileSync(d, Buffer.from(swap.value, 'utf8')) + files.push({ path: `io/${outName}`, ...sha256File(d) }) + } else { + const d = path.join(dstIo, name) + fs.copyFileSync(s, d) + files.push({ path: `io/${name}`, ...sha256File(d) }) + } + } + } + // 6b. agent-transcripts/ — copied ONLY when opts.includeAgentTranscripts. // Default-exclude rationale: the raw sidecar `.jsonl` files hold verbatim // conversation content (user prompts + assistant responses + tool_use + @@ -268,6 +380,28 @@ export function exportBundle(engagementId: string, outRootOrOpts?: string | Expo 'The verifier reads `manifest.json`, `events.jsonl`, and `operators.json`', 'from this directory and prints a summary: events walked, chain intact or', 'broken at event X, signature verified counts.', + '', + '## Scope adherence', + '', + '`scope-adherence.json` is the POSITIVE record: every target touched during', + 'this engagement and how each one classifies against the declared scope —', + 'in scope, explicitly excluded, adjacent to something in scope, or off-list.', + 'The violation list alone cannot tell you three near-misses out of 250', + 'targets from three out of five; this file carries the denominator.', + '', + 'Its `scope.provenance.digest` is the sha256 of the scope FILE the', + 'engagement was judged against. Recompute it against the scope document you', + 'issued to confirm they are the same list:', + '', + '```', + 'shasum -a 256 /path/to/the/scope/file/you/issued', + '```', + '', + 'Two fields deserve a read even when the counts look clean:', + '`scopeChanges` lists scope edits made during the engagement (the counts', + 'judge the whole window by the FINAL scope), and `disagreements` lists any', + 'target whose classification today differs from what was recorded at the', + 'time. Both empty is the ordinary case.', '' ].join('\n') const readmeDest = path.join(bundleDir, 'README.md') @@ -278,6 +412,36 @@ export function exportBundle(engagementId: string, outRootOrOpts?: string | Expo const head = computeChainHead() const lastAnchor = listAnchors(1)[0] ?? null const primary = getPrimaryOperator() + // G-D1 in the bundle. `data:exportAdherence` produces the same report as a + // loose file; here it is a hashed entry in `files`, so it inherits the + // manifest sha256 + HMAC and travels signed with everything else. Without a + // scope there is nothing to claim, so no file and a null manifest block — + // an empty report would read as "nothing was out of bounds". + let adherenceBlock: ManifestPayload['scopeAdherence'] = null + if (opts.scope && opts.scope.targets.length > 0) { + const report = buildAdherenceReport(adherenceRows, opts.scope, { + generatedAt: Date.now(), + engagementId + }) + files.push(writeAndHash( + path.join(bundleDir, 'scope-adherence.json'), + JSON.stringify(report, null, 2) + )) + adherenceBlock = { + summary: summariseAdherence(report), + totals: report.totals as unknown as Record, + scopeChanges: report.scopeChanges.length, + disagreements: report.disagreements.length, + provenance: report.scope.provenance + ? { + path: report.scope.provenance.path, + digest: report.scope.provenance.digest, + entries: report.scope.provenance.entries + } + : null + } + } + const primaryTokenHash = getPrimaryOperatorTokenHash() const manifest: ManifestPayload = { @@ -299,6 +463,7 @@ export function exportBundle(engagementId: string, outRootOrOpts?: string | Expo createdAt: lastAnchor.createdAt } : null, sanitized: { events: sanitizedRowsWritten, totalInDb: countSanitizedEvents() }, + scopeAdherence: adherenceBlock, files } diff --git a/src/core/config.ts b/src/core/config.ts index 464a137f..bb2c244a 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,4 +1,5 @@ import fs from 'fs' +import crypto from 'crypto' import path from 'path' import yaml from 'js-yaml' @@ -37,9 +38,20 @@ export interface RedLogConfig { whitelist: string[] /** blacklist — your own fixed IPs; seeing one means identity leak → EXPOSED */ blacklist: string[] + /** lanProfile — the internal segments you expect to be ON this engagement + * (e.g. the client VLAN). Judged with the same classifier as `whitelist`, + * so only three verdicts are reachable: on it, off it, or unconfigured. + * Catches a laptop that silently reassociated to a guest SSID — the + * external IP can look perfectly fine while you are on the wrong network. + * See `ALERT-ROLES.md` A.4. */ + lanProfile: string[] checkInterval: number providers: string[] confirmations: number + /** Consecutive failed reads before the safety verdict expires to 'unknown'. + * Deliberately tighter than `confirmations`: being slow to promote a new + * address is safe, being slow to expire an old verdict is not. Default: 2. */ + staleAfter: number /** how to fetch the external IP: quiet DNS, HTTP echo, or DNS→HTTP fallback */ ipMode: 'dns' | 'http' | 'auto' /** macOS gates the Wi-Fi SSID behind Location Services; opt in to request it @@ -48,12 +60,29 @@ export interface RedLogConfig { vpnAdapters: VpnAdapter[] } scope: { - /** Whether to raise a violation event + red badge when a command's target - * falls out of scope but shares a root domain with a scope target. - * Excluded targets always raise a violation regardless. Default: true. */ - warnOnViolation: boolean + /** How far down the distance ladder alerts fire (`ALERT-ROLES.md` B + C.3): + * - `excluded_only` — D1 only: targets the engagement explicitly forbade. + * - `adjacent` (default) — D1 + D2: also a target that is out of scope but + * inside a scope entry's container (same subnet as a single-IP entry, or + * same registrable domain). "Right subnet, wrong box." + * - `all` — D1 + D2 + D3: strict authorisation; any target not on the list + * goes on the record. This is what the pre-G-B3 IP path did by accident. + * The ladder is ordered, so this is a floor rather than N booleans, and D1 + * fires from every position — a fact-tier verdict is not a preference. */ + alertFloor: 'excluded_only' | 'adjacent' | 'all' targets: string[] excludeTargets: string[] + /** Container width for the D2 zone derived from a *single-IP* scope entry: + * scope `192.168.1.10` makes `192.168.1.0/24` adjacent at the default 24. + * Entries already written as CIDRs are not widened. There is deliberately + * no domain-side counterpart — the domain container is the registrable + * domain, which is not a tunable (`ALERT-ROLES.md` Part C.4). */ + proximityBits: number + /** Extra multi-label public suffixes on top of the built-in table, used to + * decide "same registrable domain" for D2. Additive; built-ins cannot be + * removed. Add one when an engagement sits on a suffix RedLog does not + * know (`public-suffix.ts` explains why the table is curated). */ + publicSuffixes: string[] scopeFile: string | null } screenshot: { @@ -100,6 +129,27 @@ export interface RedLogConfig { * `system.screenshot_pruned` audit event is appended per deletion. */ keepDays?: number } + io?: { + /** io_ref sidecar bodies (io/.bin) auto-delete after N days on + * project open. `0` (default) = keep forever, matching cast/screenshot + * conventions. The chained digest stays; a `system.io_pruned` audit event + * is appended per deletion, so a pruned body verifies as pruned, not + * tampered (SPEC-IO-SIDECAR.md). This is the *prune* age of the three-stage + * lifecycle (SPEC-SCOPE-AWARE-LIFECYCLE.md Part C). */ + keepDays?: number + /** Warm stage: compress io bodies (gzip in place → `.bin.gz`) once they + * are older than this many days. `0` (default) = never compress. The + * ORIGINAL sha256 is kept, so `redlog-verify` decompresses and re-hashes; + * reads decompress transparently. Compression is pure win before prune. */ + warmDays?: number + /** Size cap for the whole `io/` store, in bytes. `0` (default) = no cap. + * When exceeded, rotation compresses then prunes **unpinned** + * (out-of-scope / unmarked) bodies first — in-scope + loot/marker-referenced + * bodies are pinned and evicted last (SPEC-SCOPE-AWARE-LIFECYCLE.md scope-as-pin). + * A body still referenced by any event inside its window is never deleted + * (refcount-gated). */ + maxBytes?: number + } clipboard: { /** default off — clipboard is highly sensitive; opt-in per engagement */ enabled: boolean @@ -131,6 +181,10 @@ export interface RedLogConfig { events: string[] subtypes: string[] includeData: boolean + /** Lowest §3 authority tier to forward. 'inferred' (default) sends both + * tiers, labelled; 'fact' holds proximity inferences back so the blue team + * is only told about observed rule matches. See `ALERT-ROLES.md` G-C2. */ + authorityFloor: 'inferred' | 'fact' } /** Cloud-share bundle backend (spec: docs/CLOUD_SHARE_BUNDLE.md). * Nothing here is auto-populated — the operator BYO-buckets by pointing at @@ -203,17 +257,21 @@ const DEFAULT_CONFIG: RedLogConfig = { network: { whitelist: [], blacklist: [], + lanProfile: [], checkInterval: 60, providers: [], confirmations: 3, + staleAfter: 2, ipMode: 'auto', showWifiName: false, vpnAdapters: DEFAULT_VPN_ADAPTERS }, scope: { - warnOnViolation: true, + alertFloor: 'adjacent', targets: [], excludeTargets: [], + proximityBits: 24, + publicSuffixes: [], scopeFile: null }, screenshot: { @@ -236,6 +294,11 @@ const DEFAULT_CONFIG: RedLogConfig = { screenshots: { keepDays: 0 }, + io: { + keepDays: 0, + warmDays: 0, + maxBytes: 0 + }, clipboard: { enabled: false, pollMs: 1500, @@ -262,7 +325,8 @@ const DEFAULT_CONFIG: RedLogConfig = { secret: '', events: ['marker', 'system', 'credential_use', 'c2_checkin'], subtypes: ['scope_violation'], - includeData: false + includeData: false, + authorityFloor: 'inferred' }, cloudShare: { endpoint: '', @@ -311,15 +375,30 @@ function migrateConfig(parsed: Record): Record if (network.dailyIPs && !network.blacklist && !network.exposedIPs) { network.blacklist = network.dailyIPs; delete network.dailyIPs } if (network.exposedIPs && !network.blacklist) { network.blacklist = network.exposedIPs; delete network.exposedIPs } } - // scope.enforcement: 'warn'|'log' → scope.warnOnViolation: boolean. - // The old 'log' mode was misleading — it didn't actually log, it silently did - // nothing. Treat both as "warnings on" so existing users get the safer default - // instead of silently losing the badge; they can turn it off in Settings. + // scope.enforcement: 'warn' | 'log' | 'block' → scope.warnOnViolation: boolean. + // + // Only 'log' meant "stay quiet" — and it was misleading even then, since it + // did not actually log anything, it silently did nothing. Every other value, + // including the removed 'block' and anything unrecognised, migrates to + // warnings ON. That direction matters: 'block' was the STRICTEST setting the + // old field offered, so mapping it to silence would answer a request for more + // protection with less, on a config the operator never revisits because they + // believe it is already handled. An operator who wants quiet can turn it off + // in Settings; one who is quiet without asking never finds out. const scope = parsed.scope as Record | undefined - if (scope && 'enforcement' in scope && !('warnOnViolation' in scope)) { - scope.warnOnViolation = scope.enforcement === 'warn' || scope.enforcement === undefined + if (scope && 'enforcement' in scope && !('warnOnViolation' in scope) && !('alertFloor' in scope)) { + scope.warnOnViolation = scope.enforcement !== 'log' delete scope.enforcement } + // scope.warnOnViolation: boolean → scope.alertFloor (G-C3). The boolean was + // the two-value version of the same floor, so this preserves behaviour + // exactly: `false` never silenced D1 (excluded targets always raised + // regardless), which is precisely what `excluded_only` means — it does NOT + // map to a "none", because there isn't one. + if (scope && 'warnOnViolation' in scope && !('alertFloor' in scope)) { + scope.alertFloor = scope.warnOnViolation === false ? 'excluded_only' : 'adjacent' + delete scope.warnOnViolation + } return parsed } @@ -340,16 +419,124 @@ export function saveConfig(projectDir: string, config: RedLogConfig): void { fs.writeFileSync(configPath, yaml.dump(config, { lineWidth: 120 }), 'utf-8') } +/** Expand `\Q…\E` literal-quoting, at any position and however many times. + * Text outside the quoted runs is returned as-is, escapes intact. */ +function expandQuoted(s: string): string { + let out = '' + let i = 0 + while (i < s.length) { + const q = s.indexOf('\\Q', i) + if (q === -1) { out += s.slice(i); break } + out += s.slice(i, q) + const e = s.indexOf('\\E', q + 2) + out += e === -1 ? s.slice(q + 2) : s.slice(q + 2, e) + i = e === -1 ? s.length : e + 2 + } + return out +} + +// Anything still regex-shaped after decoding means the entry was not one of the +// forms Burp emits from its own UI — an operator hand-wrote a pattern. +const REGEX_META = /[\\^$*+?()[\]{}|]/ + +/** Burp/ZAP hold a scope host as a **regex**, not a hostname. Decode the shapes + * those tools actually write into RedLog target syntax: + * + * ^example\.com$ → example.com (exact host) + * \Qexample.com\E → example.com (literal-quoted) + * .*\.example\.com$ → *.example.com ("and subdomains") + * .*\Qcorp.example.com\E → *.corp.example.com (same, literal-quoted) + * + * Anything that still looks like a regex afterwards is handed back UNTOUCHED + * rather than dropped or half-converted. Such an entry matches nothing, but it + * stays visible in the scope list — a scope target that silently disappears is + * the failure with no symptom: the operator sees a scope loaded successfully + * and never learns that the hosts they were told to test are missing from it. + * + * Before this, only a `\Q` at position 0 was stripped, so Burp's own + * "and subdomains" export (`.*\Qcorp.example.com\E`) landed in the scope list + * as `*\Qcorp.example.com` and matched nothing (G-CFG2). */ +export function burpHostToTarget(host: string): string { + let s = host.trim().replace(/^\^/, '').replace(/\$$/, '') + + // Leading `.*` is Burp's "and subdomains"; the `.` or `\.` joining it to the + // host belongs to the wildcard, not to the name. + let subdomains = false + if (s.startsWith('.*')) { + subdomains = true + s = s.slice(2).replace(/^\\?\./, '') + } + + const decoded = expandQuoted(s).replace(/\\\./g, '.') + if (!decoded || REGEX_META.test(decoded)) return host + return subdomains ? `*.${decoded}` : decoded +} + +/** Where the scope RedLog judged an engagement against actually came from + * (G-D2). `ALERT-ROLES.md` D.3 lists "the scope is correct and legible, sourced + * from the authorisation document rather than typed" as RedLog's *before* + * contribution — but `scopeFile` recorded only a path, so nothing tied the + * judgement to a document a reviewer could check. The adherence report (G-D1) + * states "judged against this scope"; without this, that scope is unattributed + * exactly where the claim needs backing. */ +export interface ScopeProvenance { + path: string + /** sha256 of the file BYTES — what a reviewer recomputes to prove the scope + * document they are holding is the one the engagement was judged against. */ + digest: string + bytes: number + /** Entries the parser actually extracted. A scope file that parses to ZERO is + * the silent failure this catches: today it simply contributes no targets, + * and an operator reading "scope active" has no way to notice. */ + entries: number + /** Filesystem mtime, so a file edited in place is visible even when the + * operator never told RedLog anything changed. */ + modifiedAt: number + loadedAt: number + /** Set when the file could not be read or parsed at all. */ + error?: string +} + +/** Read a scope file and say where it came from. `loadScopeFile` is the + * targets-only wrapper the existing call sites use. `now` is injectable so the + * result is deterministic under test. */ +export function readScopeFile( + scopeFilePath: string, + now: number = Date.now() +): { targets: string[]; provenance: ScopeProvenance } { + const base: ScopeProvenance = { + path: scopeFilePath, digest: '', bytes: 0, entries: 0, modifiedAt: 0, loadedAt: now + } + let bytes: Buffer + try { + bytes = fs.readFileSync(scopeFilePath) + } catch (err) { + return { targets: [], provenance: { ...base, error: (err as Error).message } } + } + const digest = crypto.createHash('sha256').update(bytes).digest('hex') + let modifiedAt = 0 + try { modifiedAt = Math.floor(fs.statSync(scopeFilePath).mtimeMs) } catch { /* best effort */ } + + const targets = parseScopeFile(scopeFilePath, bytes.toString('utf-8')) + return { + targets, + provenance: { ...base, digest, bytes: bytes.length, entries: targets.length, modifiedAt } + } +} + export function loadScopeFile(scopeFilePath: string): string[] { + return readScopeFile(scopeFilePath).targets +} + +function parseScopeFile(scopeFilePath: string, raw: string): string[] { try { - const raw = fs.readFileSync(scopeFilePath, 'utf-8') const ext = path.extname(scopeFilePath).toLowerCase() if (ext === '.json') { const data = JSON.parse(raw) if (data.target?.scope) { return data.target.scope.flatMap((s: { host?: string }) => { - if (s.host) return [s.host.replace(/^\\Q|\\E$/g, '').replace(/^\.\*/g, '*')] + if (s.host) return [burpHostToTarget(s.host)] return [] }) } diff --git a/src/core/db/events.ts b/src/core/db/events.ts index 25cd3300..d33d0f43 100644 --- a/src/core/db/events.ts +++ b/src/core/db/events.ts @@ -1,4 +1,6 @@ import crypto from 'crypto' +import { authorityOf } from '../authority' +import { registeredAuthority } from '../event-registry' import { eventBus } from '../event-bus' import os from 'os' import { getDB } from './index' @@ -328,10 +330,30 @@ export function insertEvent( const hostname = os.hostname() const paddedMono = padMonoNs(monotonicNs()) + // K1: stamp the §3 authority before hashing, for exactly the reason the clock + // anomaly below is — a label saying "this row is an interpretation, not an + // observation" is worthless in an evidence bundle if it can be stripped + // without breaking the chain. + // + // Only `inferred` is ever written. Absence means `fact`, which is both the + // documented default (`authority.ts`) and the overwhelming majority, so this + // adds a field to detector output and to nothing else — the shape of a shell + // or marker row is unchanged. Resolving here rather than at the ~46 call + // sites means no detector can forget, and rather than in the renderer means + // there is one table, not a second copy on the far side of the process + // boundary (the renderer cannot import core — see `lib/mask.ts`). + const resolved = authorityOf({ agentType, data }, registeredAuthority) + const dataWithAuthority: Record = + resolved === 'inferred' && data.authority === undefined + ? { ...data, authority: 'inferred' } + : data + // v0.6.88 P2-A: tag the event before hashing so the anomaly is part of // the chain (a later attacker can't strip it without a hash mismatch). const anomaly = detectClockAnomaly(now, paddedMono, hostname) - const dataForChain: Record = anomaly ? { ...data, _clock_anomaly: anomaly } : data + const dataForChain: Record = anomaly + ? { ...dataWithAuthority, _clock_anomaly: anomaly } + : dataWithAuthority const event: RedLogEvent = { id: crypto.randomUUID(), diff --git a/src/core/db/index.ts b/src/core/db/index.ts index c926452e..adb8e442 100644 --- a/src/core/db/index.ts +++ b/src/core/db/index.ts @@ -134,6 +134,20 @@ export function initDB(projectDir: string): Database.Database { PRIMARY KEY (source_event_id, field) ); CREATE INDEX IF NOT EXISTS idx_sanitized_source ON sanitized_events(source_event_id); + + -- Scope-aware sanitize of io sidecar bodies (SPEC-SCOPE-AWARE-LIFECYCLE.md + -- Part B). Keyed by the ORIGINAL body digest (io bodies are deduped by + -- content, so one sanitize applies to every event that cites the sha). The + -- bundle export serves sanitized_value under io/.bin; verify + -- reads the system.sanitized digest swap and confirms the bytes hash to + -- replacement_sha256 → sanitized, not tampered. + CREATE TABLE IF NOT EXISTS sanitized_io ( + orig_sha256 TEXT PRIMARY KEY, + sanitized_value TEXT NOT NULL, + replacement_sha256 TEXT NOT NULL, + created_at INTEGER NOT NULL, + sanitized_event_id TEXT NOT NULL + ); `) // Migrate: add columns if missing (older DB versions) diff --git a/src/core/deconfliction.ts b/src/core/deconfliction.ts index 916064a6..5e8c3ca6 100644 --- a/src/core/deconfliction.ts +++ b/src/core/deconfliction.ts @@ -11,15 +11,27 @@ export interface DeconflictionConfig { events: string[] // agent_type values that should be forwarded subtypes: string[] // optional subtype values (any match forwards) includeData: boolean // include full event.data, or just metadata + description + /** Lowest §3 authority tier to forward (G-C2). 'inferred' (default) forwards + * everything, labelled; 'fact' forwards only observed rule matches, holding + * proximity inferences back. Events that carry no `authority` are unaffected + * — absence is not an inferred claim, and quietly cutting the blue team off + * from real activity is the wrong direction to fail in. */ + authorityFloor: 'inferred' | 'fact' } export const DEFAULT_DECONFLICTION: DeconflictionConfig = { enabled: false, url: '', secret: '', + // NOTE: 'system' here already forwards every system event, so the `subtypes` + // entry below is decorative for `scope_violation` — it matters only if an + // operator removes 'system' from this list. events: ['marker', 'system', 'credential_use', 'c2_checkin'], subtypes: ['scope_violation'], - includeData: false + includeData: false, + // Default forwards both tiers. Narrowing what an outward-facing feed tells + // the blue team is a deliberate act, not a default. + authorityFloor: 'inferred' } let active: DeconflictionConfig = { ...DEFAULT_DECONFLICTION } @@ -34,8 +46,18 @@ export function getDeconflictionConfig(): DeconflictionConfig { return { ...active } } +/** G-C2: the authority gate is applied AFTER the match, not inside one branch — + * an event can arrive here via `events` or via `subtypes`, and the tier rule + * must hold either way. */ +function passesAuthorityFloor(event: RedLogEvent, cfg: DeconflictionConfig): boolean { + if (cfg.authorityFloor !== 'fact') return true + const authority = event.data?.authority as string | undefined + return authority !== 'inferred' +} + function shouldForward(event: RedLogEvent, cfg: DeconflictionConfig): boolean { if (!cfg.enabled || !cfg.url) return false + if (!passesAuthorityFloor(event, cfg)) return false if (cfg.events.includes(event.agentType)) return true const subtype = (event.data?.subtype as string | undefined) ?? '' return subtype !== '' && cfg.subtypes.includes(subtype) @@ -54,6 +76,13 @@ function canonicalise(event: RedLogEvent, cfg: DeconflictionConfig): Record() export function registerEventTypes( pluginId: string, - defs: Array<{ agentType: string; label: string; lane?: string; color?: string; icon?: string }> + defs: Array<{ agentType: string; label: string; lane?: string; color?: string; icon?: string; authority?: Authority }> ): void { for (const d of defs) { if (!d.agentType || !d.label) continue @@ -33,3 +41,13 @@ export function unregisterEventTypes(pluginId: string): void { export function getEventTypes(): EventTypeDef[] { return [...registry.values()] } + +/** The `registered` lookup `authorityOf()` takes — resolves an agent_type to + * whatever authority a plugin declared for it, ignoring the pluginId prefix + * the registry keys by. */ +export function registeredAuthority(agentType: string): Authority | undefined { + for (const def of registry.values()) { + if (def.agentType === agentType && def.authority) return def.authority + } + return undefined +} diff --git a/src/core/io-store.ts b/src/core/io-store.ts new file mode 100644 index 00000000..18d7c197 --- /dev/null +++ b/src/core/io-store.ts @@ -0,0 +1,253 @@ +import fs from 'fs' +import path from 'path' +import crypto from 'crypto' +import zlib from 'zlib' +import { isInsideDir } from './paths' + +// io_ref sidecar (SPEC-IO-SIDECAR.md). Full captured HTTP request/response +// bodies (and any future large capture payload) live here as content-addressed +// files; only their sha256 enters the hash chain. This is the same +// reference-not-bytes model the .cast store already uses for terminal stdout +// (see cast-slice.ts) — generalized to arbitrary bodies. +// +// Invariant: bytes never enter the chain, only their digest. A body is written +// once per digest (dedup) to `/io/.bin`; the event carries +// `{ ref, len, sha256, ... }`. Reads are range-capable (mirrors readCastRange) +// and path-validated to stay inside `/io/`. + +/** Hard ceiling on a single range read, matching the .cast slice cap so a + * forged `len` can't ask the server to buffer an unbounded slice. */ +export const MAX_IO_READ_BYTES = 8 * 1024 * 1024 + +/** A sidecar ref is always a lowercase sha256 hex digest — its own filename + * stem. Validating the shape is the first (and strongest) traversal guard: + * a value that is not 64 hex chars can never resolve to a body we wrote. */ +const SHA256_HEX = /^[0-9a-f]{64}$/ + +export interface IoRef { + ref: string // sidecar filename stem === sha256 + len: number // full byte length of the stored body + sha256: string // digest chained in the event (=== ref) +} + +/** The sidecar directory for a project — peer of `casts/`, `screenshots/`. */ +export function ioDir(projectDir: string): string { + return path.join(projectDir, 'io') +} + +// A warm (compressed) body is `.bin.gz`. gzip, not zstd: zstd is not in the +// Node stdlib on every supported ABI, and gzip still gets ~5-10× on the +// JSON/HTML/timing text these bodies hold (SPEC-SCOPE-AWARE-LIFECYCLE.md warm +// stage). The ORIGINAL sha256 stays the filename stem, so verify decompresses +// and re-hashes against the attested digest — compression is fully reversible. +const RAW_EXT = '.bin' + +/** Resolve a ref to its canonical (uncompressed) on-disk path iff it is a + * well-formed sha256 that resolves inside the project's io/ dir. Returns null + * for anything else — the path-traversal guard shared by read and verify. + * Note: the file may actually be stored compressed (`.bin.gz`); use + * `resolveExisting` when you need the real file. */ +export function resolveRef(projectDir: string, ref: string): string | null { + if (typeof ref !== 'string' || !SHA256_HEX.test(ref)) return null + const dir = ioDir(projectDir) + const resolved = path.resolve(dir, `${ref}${RAW_EXT}`) + if (!isInsideDir(dir, resolved)) return null // defense in depth + return resolved +} + +/** The actual on-disk file for a ref — raw `.bin` or warm `.bin.gz` — and + * whether it is compressed. Null if the ref is malformed or no file exists + * (pruned). Raw is preferred when both exist (a compress that hasn't cleaned + * up yet). */ +export function resolveExisting(projectDir: string, ref: string): { file: string; compressed: boolean } | null { + const raw = resolveRef(projectDir, ref) + if (!raw) return null + if (fs.existsSync(raw)) return { file: raw, compressed: false } + const gz = raw + '.gz' // .bin.gz + if (fs.existsSync(gz)) return { file: gz, compressed: true } + return null +} + +/** Write a body to the sidecar, content-addressed and deduped by digest. + * Identical bodies across events collapse to one file. Returns the ref the + * caller stamps onto the event before it is chained. */ +export function putBody(projectDir: string, bytes: Buffer): IoRef { + const sha256 = crypto.createHash('sha256').update(bytes).digest('hex') + const dir = ioDir(projectDir) + const file = path.join(dir, `${sha256}.bin`) + // Dedup: a digest is a proof the content matches, so an existing file is the + // same bytes — never rewrite it (keeps the store append-only + cheap). A body + // already compressed to warm (`.bin.gz`) counts as present too. + if (!fs.existsSync(file) && !fs.existsSync(file + '.gz')) { + fs.mkdirSync(dir, { recursive: true }) + // Write to a temp name then rename so a concurrent reader never sees a + // half-written body under its final digest name. + const tmp = path.join(dir, `.${sha256}.${process.pid}.tmp`) + fs.writeFileSync(tmp, bytes) + try { + fs.renameSync(tmp, file) + } catch (e) { + // Lost a rename race (another writer landed the same digest first) — the + // existing file is byte-identical by construction, so just drop the temp. + try { fs.unlinkSync(tmp) } catch { /* already gone */ } + if (!fs.existsSync(file)) throw e + } + } + return { ref: sha256, len: bytes.length, sha256 } +} + +/** Read a stored body, optionally a byte range `[off, off+len)`. Returns null + * when the ref is malformed, the body is absent (pruned by retention), or the + * range is out of bounds — callers distinguish pruned-vs-tampered by checking + * the ref shape separately. Range reads are capped at MAX_IO_READ_BYTES. */ +export function readBody(projectDir: string, ref: string, off?: number, len?: number): Buffer | null { + const found = resolveExisting(projectDir, ref) + if (!found) return null // pruned / never written / malformed ref + + // Warm (compressed) body: decompress whole, then slice in memory. Warm bodies + // are the older, less-hot ones, so paying a full gunzip on read is acceptable; + // gzip has no random access anyway. + if (found.compressed) { + let whole: Buffer + try { whole = zlib.gunzipSync(fs.readFileSync(found.file)) } catch { return null } + return sliceBuffer(whole, off, len) + } + + let stat: fs.Stats + try { stat = fs.statSync(found.file) } catch { return null } + + // Whole-body read. + if (off === undefined && len === undefined) { + if (stat.size > MAX_IO_READ_BYTES) { + // Never buffer an oversized body whole; the caller must page it. + return null + } + try { return fs.readFileSync(found.file) } catch { return null } + } + + // Range read (mirrors readCastRange guards). Streamed from disk for raw bodies. + const start = off ?? 0 + const wantLen = len ?? (stat.size - start) + if (!Number.isFinite(start) || !Number.isFinite(wantLen) || start < 0 || wantLen <= 0) return null + if (wantLen > MAX_IO_READ_BYTES) return null + if (start >= stat.size) return null + const end = Math.min(start + wantLen, stat.size) + const buf = Buffer.alloc(end - start) + let fd: number + try { fd = fs.openSync(found.file, 'r') } catch { return null } + try { + const read = fs.readSync(fd, buf, 0, end - start, start) + return read === buf.length ? buf : buf.subarray(0, read) + } catch { + return null + } finally { + fs.closeSync(fd) + } +} + +/** Apply the same range/whole-with-cap semantics as the raw read path to an + * already-in-memory buffer (used after decompressing a warm body). */ +function sliceBuffer(whole: Buffer, off?: number, len?: number): Buffer | null { + if (off === undefined && len === undefined) { + return whole.length > MAX_IO_READ_BYTES ? null : whole + } + const start = off ?? 0 + const wantLen = len ?? (whole.length - start) + if (!Number.isFinite(start) || !Number.isFinite(wantLen) || start < 0 || wantLen <= 0) return null + if (wantLen > MAX_IO_READ_BYTES) return null + if (start >= whole.length) return null + return whole.subarray(start, Math.min(start + wantLen, whole.length)) +} + +/** Warm stage (SPEC-SCOPE-AWARE-LIFECYCLE.md Part C): compress a raw body in + * place to `.bin.gz`, keeping the ORIGINAL sha256 as the stem so verify + * still re-hashes to the attested digest. Returns true if it compressed (or was + * already warm), false if the ref is malformed or the body is gone. Atomic: + * writes a temp then renames, and only unlinks the raw once the gz is in place. */ +export function compressBody(projectDir: string, ref: string): boolean { + const raw = resolveRef(projectDir, ref) + if (!raw) return false + const gz = raw + '.gz' + if (fs.existsSync(gz) && !fs.existsSync(raw)) return true // already warm + if (!fs.existsSync(raw)) return false + let compressed: Buffer + try { compressed = zlib.gzipSync(fs.readFileSync(raw)) } catch { return false } + const tmp = `${gz}.${process.pid}.tmp` + try { + fs.writeFileSync(tmp, compressed) + fs.renameSync(tmp, gz) + fs.unlinkSync(raw) // reclaim the raw bytes only after the gz landed + return true + } catch { + try { fs.unlinkSync(tmp) } catch { /* already gone */ } + return false + } +} + +/** Total on-disk size of the io/ store (raw + warm), for the size trigger. */ +export function ioStoreSize(projectDir: string): number { + const dir = ioDir(projectDir) + let total = 0 + let names: string[] = [] + try { names = fs.readdirSync(dir) } catch { return 0 } + for (const n of names) { + if (!n.endsWith('.bin') && !n.endsWith('.bin.gz')) continue + try { total += fs.statSync(path.join(dir, n)).size } catch { /* raced away */ } + } + return total +} + +// The event fields the mitmproxy addon posts a full body on, and the `io.*` +// slot each maps to. The addon only sends `*_body_full` when the inline preview +// truncated (body > REDLOG_MAX_BODY), so a small body keeps its complete +// preview and never sidecars — matching the "purely additive" migration. +interface IoBodyField { + fullField: string // full body text posted by the addon (utf8) + ctField: string // content-type carried alongside + truncField: string // true if capture hit REDLOG_MAX_IO + slot: 'request' | 'response' +} +const IO_BODY_FIELDS: IoBodyField[] = [ + { fullField: 'request_body_full', ctField: 'request_body_ct', truncField: 'request_body_full_truncated', slot: 'request' }, + { fullField: 'response_body_full', ctField: 'response_body_ct', truncField: 'response_body_full_truncated', slot: 'response' }, +] + +/** Server-side (option B, SPEC-IO-SIDECAR.md §Capture path): move any posted + * full-body field into the sidecar and replace it with an `io.` ref, + * BEFORE the event is chained — so the chain closes over the sha256, never the + * bytes. Mutates `data` in place; the short `*_preview` fields are untouched. + * A no-op for events that carry no full-body field (all historical + small + * bodies), so it is safe to call on every event. */ +export function stampIoRefs(data: Record, projectDir: string): void { + for (const f of IO_BODY_FIELDS) { + const full = data[f.fullField] + // Always strip the raw full-body field, even on the paths below that skip + // sidecaring — it must never reach the chain as inline bytes. + if (typeof full !== 'string' || full.length === 0) { + delete data[f.fullField]; delete data[f.ctField]; delete data[f.truncField] + continue + } + const { ref, len, sha256 } = putBody(projectDir, Buffer.from(full, 'utf8')) + const io = (data.io && typeof data.io === 'object') ? (data.io as Record) : {} + const ref_: Record = { ref, len, sha256, truncated: data[f.truncField] === true } + if (typeof data[f.ctField] === 'string' && data[f.ctField]) ref_.ct = data[f.ctField] + io[f.slot] = ref_ + data.io = io + delete data[f.fullField]; delete data[f.ctField]; delete data[f.truncField] + } +} + +/** True iff the ref is well-formed and its stored bytes hash to the ref. A warm + * (compressed) body is decompressed first, so a compressed body still verifies + * against its ORIGINAL digest (SPEC-SCOPE-AWARE-LIFECYCLE.md A4). A mismatch is + * tampering; an absent file is pruning (caller checks existence separately). */ +export function verifyBody(projectDir: string, ref: string): boolean { + const found = resolveExisting(projectDir, ref) + if (!found) return false + let bytes: Buffer + try { + bytes = fs.readFileSync(found.file) + if (found.compressed) bytes = zlib.gunzipSync(bytes) + } catch { return false } + return crypto.createHash('sha256').update(bytes).digest('hex') === ref +} diff --git a/src/core/ip-match.ts b/src/core/ip-match.ts new file mode 100644 index 00000000..52a54618 --- /dev/null +++ b/src/core/ip-match.ts @@ -0,0 +1,162 @@ +// One address matcher for the whole alert subsystem (`ALERT-ROLES.md` G-A5). +// +// `ip-monitor.ts` and `scope-monitor.ts` each carried their own `ipToLong` + +// CIDR matcher, and both were IPv4-only in different ways: +// +// * ip-monitor compared IPv6 by STRING EQUALITY against the network part, so +// `2001:db8::1` was never inside `2001:db8::/32` and a v6 whitelist could +// not match — dropping the verdict into A-5/A-9 (or, with a v4-only +// blacklist, the A-3 fall-through, which used to answer green). +// * scope-monitor's `IP_RE` matched only dotted quads, so a v6 target was +// routed through the DOMAIN matcher: a v6 CIDR scope entry never matched, +// and on the adjacency path a v6 host fell out as `unrelated` — silent. +// Silence is the one direction this subsystem must never fail in. +// +// So: one implementation, both families, used by both monitors. + +export type IPFamily = 4 | 6 + +interface ParsedIP { + family: IPFamily + /** family 4: the address as an unsigned 32-bit number. */ + v4?: number + /** family 6: 16 bytes, network order. */ + v6?: Uint8Array +} + +/** IPv6 subnets are /64 by convention almost everywhere, so unlike the v4 + * container width there is no meaningful operator choice to expose — see + * `ALERT-ROLES.md` C.4 on not adding knobs that have one right answer. */ +export const V6_PROXIMITY_BITS = 64 + +export function parseIPv4(s: string): number | null { + const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(s) + if (!m) return null + let n = 0 + for (let i = 1; i <= 4; i++) { + const octet = Number(m[i]) + if (octet > 255) return null + n = n * 256 + octet + } + return n >>> 0 +} + +/** Expand one colon-separated run into 16-bit groups, accepting a trailing + * dotted-quad (`::ffff:192.0.2.1`, `64:ff9b::192.0.2.1`). */ +function groupsFrom(parts: string[]): number[] | null { + const out: number[] = [] + for (let i = 0; i < parts.length; i++) { + const p = parts[i] + if (p.includes('.')) { + if (i !== parts.length - 1) return null // embedded v4 must be last + const v4 = parseIPv4(p) + if (v4 === null) return null + out.push((v4 >>> 16) & 0xffff, v4 & 0xffff) + continue + } + if (!/^[0-9a-fA-F]{1,4}$/.test(p)) return null + out.push(parseInt(p, 16)) + } + return out +} + +export function parseIPv6(input: string): Uint8Array | null { + // Strip a zone id (`fe80::1%en0`) and any URL brackets before parsing. + const s = input.replace(/^\[|\]$/g, '').split('%')[0] + if (!s.includes(':')) return null + + const halves = s.split('::') + if (halves.length > 2) return null + + let groups: number[] + if (halves.length === 2) { + const head = halves[0] ? groupsFrom(halves[0].split(':')) : [] + const tail = halves[1] ? groupsFrom(halves[1].split(':')) : [] + if (!head || !tail) return null + const fill = 8 - head.length - tail.length + if (fill < 1) return null // `::` stands for at least one zero group + groups = [...head, ...new Array(fill).fill(0), ...tail] + } else { + const g = groupsFrom(s.split(':')) + if (!g || g.length !== 8) return null + groups = g + } + + const bytes = new Uint8Array(16) + for (let i = 0; i < 8; i++) { + bytes[i * 2] = (groups[i] >>> 8) & 0xff + bytes[i * 2 + 1] = groups[i] & 0xff + } + return bytes +} + +function isV4Mapped(b: Uint8Array): boolean { + for (let i = 0; i < 10; i++) if (b[i] !== 0) return false + return b[10] === 0xff && b[11] === 0xff +} + +/** Parse either family. An IPv4-mapped IPv6 address (`::ffff:10.8.0.5`) is + * reported as IPv4 — providers do return that form, and an operator whose + * whitelist says `10.8.0.0/24` means it to match. */ +export function parseIP(s: string): ParsedIP | null { + if (s.includes(':')) { + const b = parseIPv6(s) + if (!b) return null + if (isV4Mapped(b)) { + return { family: 4, v4: ((b[12] << 24) | (b[13] << 16) | (b[14] << 8) | b[15]) >>> 0 } + } + return { family: 6, v6: b } + } + const v4 = parseIPv4(s) + return v4 === null ? null : { family: 4, v4 } +} + +export function ipFamily(s: string): IPFamily | null { + return parseIP(s)?.family ?? null +} + +/** True for anything that is an IP address literal, either family. Replaces the + * dotted-quad regexes that made v6 look like a hostname. */ +export function isIPLiteral(s: string): boolean { + return parseIP(s) !== null +} + +function prefixEqual(a: Uint8Array, b: Uint8Array, bits: number): boolean { + const whole = bits >> 3 + for (let i = 0; i < whole; i++) if (a[i] !== b[i]) return false + const rest = bits & 7 + if (rest === 0) return true + const mask = (0xff << (8 - rest)) & 0xff + return (a[whole] & mask) === (b[whole] & mask) +} + +/** Is `ip` inside `cidr`? Without a `/`, this is address equality — compared as + * parsed values, not strings, so `2001:db8::1` matches the fully expanded + * `2001:0db8:0000:0000:0000:0000:0000:0001`. A malformed address, a malformed + * prefix length, or a family mismatch is `false`: this decides whether to + * raise an alert, so unparseable input must never accidentally match. */ +export function ipInCIDR(ip: string, cidr: string): boolean { + const a = parseIP(ip) + if (!a) return false + + if (!cidr.includes('/')) { + const b = parseIP(cidr) + if (!b || b.family !== a.family) return false + return a.family === 4 ? a.v4 === b.v4 : prefixEqual(a.v6!, b.v6!, 128) + } + + const slash = cidr.lastIndexOf('/') + const b = parseIP(cidr.slice(0, slash)) + if (!b || b.family !== a.family) return false + + const bitsRaw = cidr.slice(slash + 1) + if (!/^\d{1,3}$/.test(bitsRaw)) return false + const bits = Number(bitsRaw) + const max = a.family === 4 ? 32 : 128 + if (bits > max) return false + + if (a.family === 6) return prefixEqual(a.v6!, b.v6!, bits) + if (bits === 0) return true + const mask = bits === 32 ? 0xffffffff : (~((1 << (32 - bits)) - 1) >>> 0) + return (a.v4! & mask) >>> 0 === (b.v4! & mask) >>> 0 +} diff --git a/src/core/ip-monitor.ts b/src/core/ip-monitor.ts index 4f7354fd..279dc1d8 100644 --- a/src/core/ip-monitor.ts +++ b/src/core/ip-monitor.ts @@ -1,6 +1,8 @@ import { EventEmitter } from 'events' import os from 'os' import { Resolver } from 'dns/promises' +import { ipInCIDR } from './ip-match' +import type { Authority } from './authority' export type IPMode = 'dns' | 'http' | 'auto' @@ -34,14 +36,48 @@ async function getExternalIPviaDNS(): Promise { throw new Error('All DNS resolvers failed') } +/** The verdict vocabulary (`ALERT-ROLES.md` A.2). Three states could not encode + * the nine reachable cells of the combination matrix, so two of them lied: + * `presumed_safe` was reported as `safe` (an inference shown as a fact — the + * A-3 false green) and `off_profile` as `unknown` (an observed deviation shown + * as missing information). */ +export type IPSafety = 'safe' | 'presumed_safe' | 'off_profile' | 'exposed' | 'unknown' + +/** §3 tier per verdict (K1). `null` = the verdict makes no claim either way, + * which is what `unknown` means. This is what stops a surface from having to + * re-derive "is this an inference?" from the verdict name. */ +export function verdictAuthority(v: IPSafety): Authority | null { + if (v === 'presumed_safe') return 'inferred' + if (v === 'unknown') return null + return 'fact' +} + export interface IPStatus { externalIP: string | null internalIP: string | null - ipSafety: 'safe' | 'exposed' | 'unknown' + ipSafety: IPSafety lastCheck: number error: string | null /** True while a new address is being confirmed — the displayed one is the last stable read. */ settling: boolean + /** A-4 (G-A4): the INTERNAL address against the engagement's expected LAN + * segments. `internalIP` was collected and displayed but never judged, so a + * laptop that silently reassociated to a guest SSID mid-engagement looked + * exactly like one still on the client VLAN. Reuses `classifyIP` with the + * lan profile as the whitelist and no blacklist, so only three of the nine + * cells are reachable — `safe`, `off_profile`, `unknown`. There is + * deliberately no LAN blacklist: "this is my own segment" is what the + * profile already says, from the other direction. */ + lanSafety: IPSafety + /** A-6: the current address matches both the whitelist and the blacklist. + * The verdict stays `exposed`; this surfaces the contradictory config. */ + listConflict: boolean + /** Consecutive failed reads. Reset to 0 by any success. */ + consecutiveFailures: number + /** The verdict is no longer backed by a current reading: `consecutiveFailures` + * reached `staleAfter`, so `ipSafety` has decayed to 'unknown'. The displayed + * address is still the last known one. */ + stale: boolean /** active link: Wi-Fi SSID or wired (populated by main, not the monitor) */ link?: { type: 'wifi' | 'wired' | 'unknown'; name: string } } @@ -59,21 +95,62 @@ const DEFAULT_IP_PROVIDERS = [ // been seen this many times in a row. const DEFAULT_CONFIRMATIONS = 3 -function ipToLong(ip: string): number { - return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0) >>> 0 -} +// A verdict outliving the reading it was based on is the same class of lie as the +// A-9 false green: a VPN kill-switch dropping the network is EXACTLY when the +// external-IP lookup starts failing, and until now the badge kept showing the +// last verdict — often green — indefinitely, with only `lastCheck` moving. +// +// Why 2 and not 3, matching `confirmations`: the two thresholds look alike but +// point opposite ways. Being SLOW to promote a new address is safe — you keep +// showing a verdict you verified. Being slow to expire an old one is not: you +// keep showing a verdict you can no longer stand behind. So the decay threshold +// is deliberately tighter than the promotion threshold. One failure can be a +// single provider hiccup; two in a row at the shipped 60s poll means two +// minutes of no contact, which is not a blip. +const DEFAULT_STALE_AFTER = 2 + +/** Pure IP classification — no side effects, the test seam for the verdict + * combination matrix (`ALERT-ROLES.md` Part A). `IPMonitor.classify()` is a + * thin delegate; this is the sibling of `scope-monitor.ts` `classifyTarget()`. */ +export function classifyIP( + ip: string, + cfg: { whitelist: string[]; blacklist: string[] } +): IPSafety { + // Blacklist (your own IP) wins: seeing it means your real identity is + // leaking, which is the alert that must never be masked by a whitelist hit. + if (cfg.blacklist.length > 0 && cfg.blacklist.some((cidr) => ipInCIDR(ip, cidr))) return 'exposed' + + // Declaring a whitelist declares an expectation, so being outside it is an + // OBSERVED DEVIATION — a fact, not missing information. G-A1 stopped this + // answering 'safe' (the VPN-dropped-onto-café-NAT false green); G-A2 stops it + // answering 'unknown', which understated it into the same amber bucket as + // "nothing is configured at all". + if (cfg.whitelist.length > 0) { + return cfg.whitelist.some((cidr) => ipInCIDR(ip, cidr)) ? 'safe' : 'off_profile' + } -function isIPv6(ip: string): boolean { - return ip.includes(':') + // Blacklist-only mode: configured but unmatched. This is an INFERENCE — "not + // obviously you" is not the same statement as "on the list you approved" — + // and it used to be reported as plain `safe`, i.e. an inference wearing a + // fact's solid green. It gets its own verdict so the UI can render it as the + // qualified claim it is. + if (cfg.blacklist.length > 0) return 'presumed_safe' + return 'unknown' } -function ipInCIDR(ip: string, cidr: string): boolean { - if (!cidr.includes('/')) return ip === cidr - if (isIPv6(ip) !== isIPv6(cidr)) return false - if (isIPv6(ip)) return ip === cidr.split('/')[0] - const [network, bits] = cidr.split('/') - const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0 - return (ipToLong(ip) & mask) === (ipToLong(network) & mask) +/** A-6: the address is on BOTH lists. The verdict is `exposed` and that is + * correct — the blacklist wins — but the operator has a contradictory config + * they will never discover from a red badge that looks like every other red + * badge. Reported alongside the verdict rather than as one, because it says + * something about the CONFIG, not about where the operator is. */ +export function hasListConflict( + ip: string, + cfg: { whitelist: string[]; blacklist: string[] } +): boolean { + return ( + cfg.blacklist.some((cidr) => ipInCIDR(ip, cidr)) && + cfg.whitelist.some((cidr) => ipInCIDR(ip, cidr)) + ) } function getInternalIP(): string | null { @@ -123,9 +200,11 @@ export class IPMonitor extends EventEmitter { private interval: ReturnType | null = null private whitelist: string[] = [] private blacklist: string[] = [] + private lanProfile: string[] = [] private checkIntervalMs = 10_000 private providers: string[] = [...DEFAULT_IP_PROVIDERS] private confirmations = DEFAULT_CONFIRMATIONS + private staleAfter = DEFAULT_STALE_AFTER private ipMode: IPMode = 'auto' private pendingIP: string | null = null private pendingCount = 0 @@ -133,9 +212,13 @@ export class IPMonitor extends EventEmitter { externalIP: null, internalIP: null, ipSafety: 'unknown', + lanSafety: 'unknown', lastCheck: 0, error: null, - settling: false + settling: false, + listConflict: false, + consecutiveFailures: 0, + stale: false } get status(): IPStatus { @@ -145,18 +228,24 @@ export class IPMonitor extends EventEmitter { configure(opts: { whitelist?: string[] blacklist?: string[] + lanProfile?: string[] checkInterval?: number providers?: string[] confirmations?: number + staleAfter?: number ipMode?: IPMode }): void { if (opts.whitelist) this.whitelist = opts.whitelist if (opts.blacklist) this.blacklist = opts.blacklist + if (opts.lanProfile) this.lanProfile = opts.lanProfile if (opts.checkInterval) this.checkIntervalMs = opts.checkInterval * 1000 if (opts.providers?.length) this.providers = opts.providers if (typeof opts.confirmations === 'number' && opts.confirmations > 0) { this.confirmations = opts.confirmations } + if (typeof opts.staleAfter === 'number' && opts.staleAfter > 0) { + this.staleAfter = Math.floor(opts.staleAfter) + } if (opts.ipMode) this.ipMode = opts.ipMode } @@ -168,14 +257,19 @@ export class IPMonitor extends EventEmitter { return getExternalIPviaDNS().catch(() => getExternalIPviaHTTP(this.providers)) } - private classify(ip: string): IPStatus['ipSafety'] { - // Blacklist (your own IP) wins: seeing it means your real identity is - // leaking, which is the alert that must never be masked by a whitelist hit. - if (this.blacklist.length > 0 && this.blacklist.some((cidr) => ipInCIDR(ip, cidr))) return 'exposed' - if (this.whitelist.length > 0 && this.whitelist.some((cidr) => ipInCIDR(ip, cidr))) return 'safe' - // Blacklist mode: blacklist configured but IP didn't match → behind VPN/tunnel → safe - if (this.blacklist.length > 0) return 'safe' - return 'unknown' + private classify(ip: string): IPSafety { + return classifyIP(ip, { whitelist: this.whitelist, blacklist: this.blacklist }) + } + + /** Same classifier, different list. No LAN-specific verdict vocabulary — the + * doc predicted the whitelist machinery would serve this, and it does. */ + private classifyLan(ip: string | null): IPSafety { + if (!ip) return 'unknown' + return classifyIP(ip, { whitelist: this.lanProfile, blacklist: [] }) + } + + private conflict(ip: string): boolean { + return hasListConflict(ip, { whitelist: this.whitelist, blacklist: this.blacklist }) } start(): void { @@ -206,12 +300,23 @@ export class IPMonitor extends EventEmitter { // Still on the known address — drop any half-confirmed candidate. this.pendingIP = null this.pendingCount = 0 + // Re-classify even though the address is unchanged. Two ways this + // branch used to strand a wrong verdict: (1) a stale decay to 'unknown' + // never recovered if the network came back on the SAME exit IP — the + // common case for a VPN blip; (2) editing the safe/exposed lists in + // Settings did not move the badge until the address happened to change. + // The verdict is a pure function of (address, lists), so derive it. this._status = { ...this._status, + ipSafety: this.classify(externalIP), + listConflict: this.conflict(externalIP), internalIP, + lanSafety: this.classifyLan(internalIP), lastCheck: Date.now(), error: null, - settling: false + settling: false, + consecutiveFailures: 0, + stale: false } } else { this.pendingCount = externalIP === this.pendingIP ? this.pendingCount + 1 : 1 @@ -226,26 +331,52 @@ export class IPMonitor extends EventEmitter { externalIP, internalIP, ipSafety: this.classify(externalIP), + listConflict: this.conflict(externalIP), + lanSafety: this.classifyLan(internalIP), lastCheck: Date.now(), error: null, - settling: false + settling: false, + consecutiveFailures: 0, + stale: false } } else { // Keep showing the last stable address, but say we're unsure. this._status = { ...this._status, internalIP, + lanSafety: this.classifyLan(internalIP), lastCheck: Date.now(), error: null, - settling: true + settling: true, + consecutiveFailures: 0, + stale: false } } } } catch (err) { + // G-A3. The decay is uniform: an `exposed` verdict decays too. Holding a + // red alarm on a reading we can no longer confirm is the same dishonesty + // as holding a green one — 'unknown' is what we actually know. The last + // seen address stays on screen so nothing is lost, only re-labelled. + const consecutiveFailures = this._status.consecutiveFailures + 1 + const stale = consecutiveFailures >= this.staleAfter + // The internal address is a LOCAL read of the network interfaces — it + // does not depend on the lookup that just failed. `Promise.all` above + // discards it along with the rejection, so re-read it here rather than + // letting a dead internet blank out a fact we still have. It matters + // more since G-A4: dropping off the client VLAN is MORE likely when the + // network is misbehaving, which is exactly when the external poll fails. + // `lanSafety` therefore never goes stale — nothing about it expired. + const internalIP = getInternalIP() this._status = { ...this._status, + ipSafety: stale ? 'unknown' : this._status.ipSafety, + internalIP, + lanSafety: this.classifyLan(internalIP), lastCheck: Date.now(), - error: err instanceof Error ? err.message : 'Unknown error' + error: err instanceof Error ? err.message : 'Unknown error', + consecutiveFailures, + stale } } finally { this.checking = false diff --git a/src/core/plugins/types.ts b/src/core/plugins/types.ts index 75d82c07..0d75ace5 100644 --- a/src/core/plugins/types.ts +++ b/src/core/plugins/types.ts @@ -95,6 +95,12 @@ export interface EventTypeContribution { color?: string /** short emoji/glyph shown in lists */ icon?: string + /** §3 (K1): does this type record an observation or a judgement? Unset means + * `fact`. An inferred type renders dashed on the timeline instead of solid, + * so a detector plugin that asserts its guesses as facts is a bug the type + * system can now catch. If the type emits both, leave this unset and stamp + * `data.authority` per event — that always wins. */ + authority?: 'fact' | 'inferred' } /** 🟢 A capture integration — mirrors hooks-manager's PluginManifest shape. */ diff --git a/src/core/public-suffix.ts b/src/core/public-suffix.ts new file mode 100644 index 00000000..6b275458 --- /dev/null +++ b/src/core/public-suffix.ts @@ -0,0 +1,102 @@ +// Registrable-domain derivation for scope adjacency (`ALERT-ROLES.md` B.4, G-B2). +// +// The D2 `adjacent_domain` rule asks "is this host under the same *registrable* +// domain as a scope entry?" The previous answer — take the last two labels — +// makes `co.uk` the registrable domain of `shop.example.co.uk`, so a scope of +// `*.example.co.uk` marks every `.co.uk` host on the internet as adjacent. The +// same bug hits `github.io`, `s3.amazonaws.com` and `azurewebsites.net`, which +// matters more in practice: a scope of `target.github.io` would otherwise make +// every GitHub Pages site a near-miss. +// +// WHY A CURATED TABLE AND NOT THE FULL PUBLIC SUFFIX LIST +// +// The full PSL means a runtime dependency (tldts/psl, ~3 MB). RedLog ships eight +// runtime dependencies on purpose; for an evidence tool, supply-chain surface is +// a cost paid by every user. The deciding factor is the failure direction: +// +// * A suffix MISSING from this table → the fallback takes the last two labels +// → over-match → a noisier D2. That is exactly today's behaviour, so an +// incomplete table degrades to the status quo and never below it. +// * A suffix WRONGLY in this table → hosts that share an owner get different +// registrable domains → D3 → SILENCE. For an alerting subsystem with no +// second line of defence, that is the unacceptable direction. +// +// So the table is deliberately conservative: only entries that are unambiguously +// public suffixes. Anything doubtful is left out (costing noise, not silence) and +// operators extend it per engagement via `scope.publicSuffixes`. + +/** Multi-label public suffixes. Single-label TLDs (`com`, `io`, `dev`) are NOT + * listed — the fallback already handles them by taking the last two labels. */ +const BUILT_IN_SUFFIXES: readonly string[] = [ + // --- ccTLD second levels --- + 'co.uk', 'org.uk', 'me.uk', 'ltd.uk', 'plc.uk', 'net.uk', 'sch.uk', 'ac.uk', 'gov.uk', 'nhs.uk', 'police.uk', 'mod.uk', + 'com.au', 'net.au', 'org.au', 'edu.au', 'gov.au', 'asn.au', 'id.au', + 'co.jp', 'or.jp', 'ne.jp', 'ac.jp', 'ad.jp', 'ed.jp', 'go.jp', 'gr.jp', 'lg.jp', + 'com.tw', 'net.tw', 'org.tw', 'edu.tw', 'gov.tw', 'idv.tw', 'game.tw', 'ebiz.tw', 'club.tw', + 'com.cn', 'net.cn', 'org.cn', 'gov.cn', 'edu.cn', 'ac.cn', 'mil.cn', + 'com.hk', 'net.hk', 'org.hk', 'edu.hk', 'gov.hk', 'idv.hk', + 'co.kr', 'or.kr', 'ne.kr', 're.kr', 'pe.kr', 'go.kr', 'ac.kr', 'mil.kr', + 'com.sg', 'net.sg', 'org.sg', 'edu.sg', 'gov.sg', 'per.sg', + 'co.in', 'net.in', 'org.in', 'gen.in', 'firm.in', 'ind.in', 'gov.in', 'ac.in', 'edu.in', 'res.in', 'mil.in', + 'co.id', 'or.id', 'net.id', 'web.id', 'ac.id', 'go.id', 'sch.id', 'my.id', 'biz.id', + 'co.th', 'in.th', 'ac.th', 'go.th', 'or.th', 'net.th', + 'com.my', 'net.my', 'org.my', 'gov.my', 'edu.my', 'mil.my', + 'com.ph', 'net.ph', 'org.ph', 'gov.ph', 'edu.ph', + 'com.vn', 'net.vn', 'org.vn', 'gov.vn', 'edu.vn', 'ac.vn', + 'com.br', 'net.br', 'org.br', 'gov.br', 'edu.br', 'mil.br', + 'com.ar', 'net.ar', 'org.ar', 'gov.ar', 'edu.ar', 'int.ar', 'mil.ar', + 'com.mx', 'org.mx', 'net.mx', 'edu.mx', 'gob.mx', + 'co.za', 'org.za', 'net.za', 'gov.za', 'ac.za', 'web.za', 'edu.za', 'mil.za', + 'co.nz', 'net.nz', 'org.nz', 'govt.nz', 'ac.nz', 'school.nz', + 'com.tr', 'net.tr', 'org.tr', 'gov.tr', 'edu.tr', 'mil.tr', 'k12.tr', 'bel.tr', 'pol.tr', + 'co.il', 'org.il', 'net.il', 'ac.il', 'gov.il', 'muni.il', 'idf.il', + 'com.ru', 'net.ru', 'org.ru', 'edu.ru', + 'com.ua', 'net.ua', 'org.ua', 'gov.ua', 'edu.ua', 'in.ua', + 'com.pl', 'net.pl', 'org.pl', 'gov.pl', 'edu.pl', + 'com.es', 'org.es', 'nom.es', 'gob.es', 'edu.es', + 'gov.it', 'edu.it', + + // --- Platform / SaaS suffixes. Under the old rule a scope of + // `target.github.io` made every GitHub Pages site adjacent; in bug-bounty + // and red-team scopes these matter more than the ccTLDs above. + 'github.io', 'gitlab.io', + 'pages.dev', 'workers.dev', 'r2.dev', + 'vercel.app', 'netlify.app', + 'herokuapp.com', + 'appspot.com', 'web.app', 'firebaseapp.com', 'cloudfunctions.net', + 'azurewebsites.net', 'blob.core.windows.net', 'azureedge.net', + 'cloudfront.net', 's3.amazonaws.com', 'elasticbeanstalk.com', 'amplifyapp.com', + 'onrender.com', 'fly.dev', + 'glitch.me', 'repl.co', 'replit.dev', 'surge.sh', 'readthedocs.io' + // Known gap: regional S3 forms (`s3.us-east-1.amazonaws.com`) are absent, so + // they fall back to `amazonaws.com`. That over-matches — noise, not silence. +] + +export function buildSuffixSet(extra: readonly string[] = []): Set { + const set = new Set(BUILT_IN_SUFFIXES) + for (const s of extra) { + const trimmed = s.trim().toLowerCase().replace(/^\*\./, '').replace(/^\.+|\.+$/g, '') + if (trimmed.includes('.')) set.add(trimmed) + } + return set +} + +export const DEFAULT_SUFFIXES: Set = buildSuffixSet() + +/** The registrable domain: the public suffix plus one label. Longest suffix + * wins, so `example.co.uk` beats a hypothetical bare `uk` entry. Falls back to + * the last two labels when no multi-label suffix matches — which is correct for + * every ordinary TLD (`example.com`) and is the safe over-matching default for + * any suffix the table has not heard of. */ +export function getRegistrableDomain(host: string, suffixes: Set = DEFAULT_SUFFIXES): string { + const labels = host.toLowerCase().split('.').filter(Boolean) + if (labels.length <= 2) return labels.join('.') + + for (let i = 0; i < labels.length; i++) { + const candidate = labels.slice(i).join('.') + if (!suffixes.has(candidate)) continue + // The host IS the suffix (`co.uk`) — there is nothing shorter to return. + return i === 0 ? candidate : labels.slice(i - 1).join('.') + } + return labels.slice(-2).join('.') +} diff --git a/src/core/retention.ts b/src/core/retention.ts index a7c702d8..a86297a4 100644 --- a/src/core/retention.ts +++ b/src/core/retention.ts @@ -10,9 +10,12 @@ import fs from 'fs' import path from 'path' import { getProjectDir, getDB } from './db/index' -import { insertEvent } from './db/events' +import { insertEvent, queryEvents } from './db/events' import { eventBus } from './event-bus' import { noteDbError } from './capture-health' +import { compressBody, resolveExisting, ioDir } from './io-store' +import { planArtifactRotation, type ArtifactBody } from './artifact-gc' +import { isPinned, pinScore, type ScopeVerdict } from './artifact-pin' // v0.6.89 `_causes`: cast_pruned and screenshot_pruned should reference the // upstream event so focus chain walks light up the "originally recorded here @@ -91,14 +94,124 @@ function sweepDir( return pruned } +// Strongest scope verdict wins when a deduped body is referenced by events of +// differing scope — in_scope pins hardest, excluded weakest. Mirrors the pin +// tier order so a body cited by any in-scope event is kept longest. +const SCOPE_STRENGTH: Record = { in_scope: 3, unknown: 2, out_of_scope: 1, excluded: 0 } +function strongerScope(a: ScopeVerdict, b: ScopeVerdict): ScopeVerdict { + return SCOPE_STRENGTH[b] > SCOPE_STRENGTH[a] ? b : a +} + +/** io_ref sidecar lifecycle sweep (SPEC-SCOPE-AWARE-LIFECYCLE.md Part C). + * Unlike the file-mtime `sweepDir` used for casts/screenshots, io bodies are + * content-addressed and deduped, so pruning is a mark-and-sweep GC over + * event→sha refs: a body is age-prunable only when its NEWEST referencing event + * is past the window, and under size pressure UNPINNED (out-of-scope / unmarked) + * bodies are evicted before pinned ones. Warm bodies are compressed first. + * + * `resolveScope` is optional: without it every body's scope is `unknown`, so + * pinning still honours marker/loot references and operator pins, but scope + * priority is inert until a pure classifier is supplied (Part B feeds it). */ +export function sweepIoLifecycle( + projectDir: string, + cfg: { keepDays: number; warmDays: number; maxBytes: number }, + opts: { engagementId: string; operatorId: string; resolveScope?: (target: string) => ScopeVerdict } +): { compressed: number; pruned: number } { + const dir = ioDir(projectDir) + if (!opts.operatorId || !fs.existsSync(dir)) return { compressed: 0, pruned: 0 } + // Nothing configured (keep-forever, no warm, no cap) → no work. + if (cfg.keepDays <= 0 && cfg.warmDays <= 0 && cfg.maxBytes <= 0) return { compressed: 0, pruned: 0 } + + const now = Date.now() + const events = queryEvents({ limit: 100000 }) + + // sha → referencing-event facts (newest use, owning ids, targets). + const refs = new Map; targets: Set }>() + // event ids cited by any marker/loot `_causes` — their referenced io bodies pin. + const citedIds = new Set() + for (const e of events) { + if (e.agentType === 'marker' || e.agentType === 'loot') { + const causes = (e.data?._causes as unknown[] | undefined) + if (Array.isArray(causes)) for (const c of causes) if (typeof c === 'string') citedIds.add(c) + } + const io = e.data?.io as { request?: { ref?: unknown }; response?: { ref?: unknown } } | undefined + if (!io) continue + for (const slot of ['request', 'response'] as const) { + const ref = io[slot]?.ref + if (typeof ref !== 'string') continue + const cur = refs.get(ref) ?? { newestTs: 0, ownerIds: new Set(), targets: new Set() } + cur.newestTs = Math.max(cur.newestTs, e.timestamp) + cur.ownerIds.add(e.id) + const tgt = (e.targetId ?? (e.data?.detectedTarget as string) ?? (e.data?.host as string)) || '' + if (tgt) cur.targets.add(tgt) + refs.set(ref, cur) + } + } + + // Build the on-disk body list with each body's resolved facts. + const bodies: ArtifactBody[] = [] + let names: string[] = [] + try { names = fs.readdirSync(dir) } catch { return { compressed: 0, pruned: 0 } } + for (const name of names) { + const compressed = name.endsWith('.bin.gz') + if (!name.endsWith('.bin') && !compressed) continue + const sha = name.replace(/\.bin(\.gz)?$/, '') + let size = 0, mtimeMs = now + try { const st = fs.statSync(path.join(dir, name)); size = st.size; mtimeMs = st.mtimeMs } catch { continue } + const r = refs.get(sha) + // Refcount gate: age is the NEWEST referencing event's age; an orphan body + // (no live reference) falls back to its file age. + const newest = r?.newestTs ?? mtimeMs + const ageDays = Math.max(0, (now - newest) / DAY_MS) + let scope: ScopeVerdict = 'unknown' + if (opts.resolveScope && r) for (const t of r.targets) scope = strongerScope(scope, opts.resolveScope(t)) + const referencedByMarkerOrLoot = !!r && [...r.ownerIds].some((id) => citedIds.has(id)) + const pin = { scope, referencedByMarkerOrLoot, operatorPinned: false } + bodies.push({ sha, bytes: size, compressed, ageDays, pruneDays: cfg.keepDays, pinned: isPinned(pin), pinScore: pinScore(pin) }) + } + + const plan = planArtifactRotation(bodies, { warmDays: cfg.warmDays, maxBytes: cfg.maxBytes }) + + // Warm first (pure win, no audit event — reversible + verify-transparent). + let compressed = 0 + for (const sha of plan.toCompress) if (compressBody(projectDir, sha)) compressed++ + + // Then prune — each deletion appends a chained system.io_pruned so a missing + // body reads as pruned-by-policy, never tampered. + let pruned = 0 + for (const sha of plan.toPrune) { + const found = resolveExisting(projectDir, sha) + if (!found) continue + let size = 0 + try { size = fs.statSync(found.file).size } catch { /* raced */ } + try { + fs.unlinkSync(found.file) + pruned++ + try { + const ev = insertEvent('system', { + subtype: 'io_pruned', + path: found.file, + bytes: size, + sha256: sha, + description: `pruned by io retention (${cfg.keepDays}d / ${cfg.maxBytes || '∞'}B)` + }, opts) + if (ev) eventBus.publish(ev) + } catch (e) { noteDbError('io-lifecycle-sweep', e) } + } catch { /* file gone / permission — skip */ } + } + return { compressed, pruned } +} + export function sweepRetention(config: { terminal?: { castKeepDays?: number } screenshots?: { keepDays?: number } agentTranscripts?: { keepDays?: number } -}, opts: { engagementId: string; operatorId: string }): { cast: number; screenshots: number; agentTranscripts: number } { - if (!opts.operatorId) return { cast: 0, screenshots: 0, agentTranscripts: 0 } + io?: { keepDays?: number; warmDays?: number; maxBytes?: number } +}, opts: { engagementId: string; operatorId: string; resolveScope?: (target: string) => ScopeVerdict }): { cast: number; screenshots: number; agentTranscripts: number; io: number; ioCompressed: number } { + const zero = { cast: 0, screenshots: 0, agentTranscripts: 0, io: 0, ioCompressed: 0 } + if (!opts.operatorId) return zero let projectDir: string - try { projectDir = getProjectDir() } catch { return { cast: 0, screenshots: 0, agentTranscripts: 0 } } + try { projectDir = getProjectDir() } catch { return zero } const castDays = config.terminal?.castKeepDays ?? 0 const shotDays = config.screenshots?.keepDays ?? 0 // v0.7.2 F: agent-transcripts sweep. @@ -134,5 +247,14 @@ export function sweepRetention(config: { 'agent_transcript_pruned', opts ) - return { cast, screenshots, agentTranscripts } + // io_ref sidecar bodies (SPEC-SCOPE-AWARE-LIFECYCLE.md Part C): a full + // lifecycle GC, not a flat file-mtime sweep — refcount-gated, size-aware, + // scope-pinned, with a warm (compress) stage before prune. The chain (digests + // only) is untouched, so a pruned body verifies as *pruned*, not tampered. + const ioResult = sweepIoLifecycle( + projectDir, + { keepDays: config.io?.keepDays ?? 0, warmDays: config.io?.warmDays ?? 0, maxBytes: config.io?.maxBytes ?? 0 }, + opts + ) + return { cast, screenshots, agentTranscripts, io: ioResult.pruned, ioCompressed: ioResult.compressed } } diff --git a/src/core/scope-adherence.ts b/src/core/scope-adherence.ts new file mode 100644 index 00000000..f6fc0c8b --- /dev/null +++ b/src/core/scope-adherence.ts @@ -0,0 +1,203 @@ +// Scope-adherence report — the POSITIVE proof (`ALERT-ROLES.md` D.3, G-D1). +// +// RedLog does not prevent (Part D). Its deliverable is therefore "provably did +// not exceed scope", not "was unable to" — and until now nothing produced that +// proof. `data:exportViolations` shows the violations, which is the ACCUSATION +// half; a client reading it has no way to tell 3 near-misses out of 250 targets +// from 3 out of 5. This builds the other half: every target touched, how each +// one classifies, and the denominator. +// +// It is a pure function of (events, scope) so it can be tested without a DB and +// so the same code answers for a live session, an export, and a bundle. + +import { classifyDistance, type ScopeDistance, type ViolationReason } from './scope-monitor' +import type { ScopeProvenance } from './config' + +interface EventLike { + timestamp: number + agentType: string + targetId?: string | null + data?: Record | null +} + +export interface ScopeConfigSnapshot { + targets: string[] + excludeTargets: string[] + proximityBits?: number + publicSuffixes?: string[] + alertFloor?: string + /** G-D2: where the scope came from. A report that says "judged against this + * scope" is only as good as the reviewer's ability to check that scope — the + * digest is what lets them recompute it against the authorisation document + * they were given. Null when the operator typed the targets in directly. */ + provenance?: ScopeProvenance | null +} + +export interface TargetRow { + target: string + distance: ScopeDistance + firstSeen: number + lastSeen: number + /** How many operator actions hit this target. */ + count: number + /** A sample of the commands, capped — the full record is the timeline. */ + commands: string[] +} + +export interface RecordedViolation { + target: string + reason: ViolationReason + authority: 'fact' | 'inferred' + timestamp: number +} + +export interface ScopeAdherenceReport { + generatedAt: number + engagementId: string | null + scope: ScopeConfigSnapshot + /** Counts by distance, plus the denominator. `targets` is DISTINCT hosts. */ + totals: Record & { targets: number; actions: number } + targets: TargetRow[] + /** Violations exactly as they were recorded at the time, from the chain. */ + recordedViolations: RecordedViolation[] + /** Scope edits inside the window. Re-classification below uses the CURRENT + * scope, so a non-empty list here means the counts describe the engagement + * as judged by today's scope — not necessarily the one in force at the time. + * Surfacing it is the difference between a caveat and a silent error. */ + scopeChanges: Array<{ timestamp: number; changed: Record }> + /** Targets whose live classification differs from what was recorded. Empty is + * the expected case; a non-empty list is the honest consequence of a scope + * edit mid-engagement, not a bug. */ + disagreements: Array<{ target: string; recorded: ViolationReason; current: ScopeDistance }> +} + +const MAX_COMMAND_SAMPLES = 5 +const COMMAND_SLICE = 200 + +/** What the operator aimed an action at. `system` events are RedLog's own + * bookkeeping — a `scope_violation` carries the offending host in `targetId`, + * and counting it would inflate the very target it is reporting on. */ +function targetOf(e: EventLike): string | null { + if (e.agentType === 'system') return null + const detected = e.data?.detectedTarget + if (typeof detected === 'string' && detected) return detected + return e.targetId || null +} + +function distanceMatchesReason(distance: ScopeDistance, reason: ViolationReason): boolean { + if (reason === 'excluded_target') return distance === 'excluded' + return distance === reason +} + +export function buildAdherenceReport( + events: readonly EventLike[], + scope: ScopeConfigSnapshot, + opts: { generatedAt: number; engagementId?: string | null } +): ScopeAdherenceReport { + const rows = new Map() + const recordedViolations: RecordedViolation[] = [] + const scopeChanges: ScopeAdherenceReport['scopeChanges'] = [] + let actions = 0 + + for (const e of events) { + const sub = e.data?.subtype as string | undefined + + if (e.agentType === 'system' && sub === 'scope_violation') { + recordedViolations.push({ + target: String(e.data?.target ?? e.targetId ?? ''), + reason: (e.data?.reason as ViolationReason) ?? 'unrelated', + authority: (e.data?.authority as 'fact' | 'inferred') ?? 'fact', + timestamp: e.timestamp + }) + continue + } + + if (e.agentType === 'system' && sub === 'config_changed') { + const changed = (e.data?.changed as Record) ?? {} + const scopeKeys = Object.keys(changed).filter((k) => k.startsWith('scope.')) + if (scopeKeys.length > 0) { + scopeChanges.push({ + timestamp: e.timestamp, + changed: Object.fromEntries(scopeKeys.map((k) => [k, changed[k]])) + }) + } + continue + } + + const target = targetOf(e) + if (!target) continue + actions += 1 + + const existing = rows.get(target) + const command = typeof e.data?.command === 'string' ? e.data.command.slice(0, COMMAND_SLICE) : null + if (existing) { + existing.count += 1 + existing.firstSeen = Math.min(existing.firstSeen, e.timestamp) + existing.lastSeen = Math.max(existing.lastSeen, e.timestamp) + if (command && existing.commands.length < MAX_COMMAND_SAMPLES && !existing.commands.includes(command)) { + existing.commands.push(command) + } + } else { + rows.set(target, { + target, + distance: classifyDistance(target, scope), + firstSeen: e.timestamp, + lastSeen: e.timestamp, + count: 1, + commands: command ? [command] : [] + }) + } + } + + const totals: ScopeAdherenceReport['totals'] = { + in_scope: 0, excluded: 0, adjacent_subnet: 0, adjacent_domain: 0, unrelated: 0, + targets: rows.size, actions + } + for (const row of rows.values()) totals[row.distance] += 1 + + // Worst first, then by how much traffic each took — a client reading this + // wants the problems at the top, not an alphabetical list. + const ORDER: Record = { + excluded: 0, adjacent_subnet: 1, adjacent_domain: 2, unrelated: 3, in_scope: 4 + } + const targets = [...rows.values()].sort( + (a, b) => ORDER[a.distance] - ORDER[b.distance] || b.count - a.count || a.target.localeCompare(b.target) + ) + + const seenDisagreement = new Set() + const disagreements: ScopeAdherenceReport['disagreements'] = [] + for (const v of recordedViolations) { + const row = rows.get(v.target) + if (!row || seenDisagreement.has(v.target)) continue + if (distanceMatchesReason(row.distance, v.reason)) continue + seenDisagreement.add(v.target) + disagreements.push({ target: v.target, recorded: v.reason, current: row.distance }) + } + + return { + generatedAt: opts.generatedAt, + engagementId: opts.engagementId ?? null, + scope, + totals, + targets, + recordedViolations, + scopeChanges, + disagreements + } +} + +/** One line a human reads first — the shape of the claim the report supports. + * "247 targets, 244 in scope, 0 excluded, 3 adjacent" is the sentence a client + * deliverable needs; the arrays below it are the evidence for it. */ +export function summariseAdherence(r: ScopeAdherenceReport): string { + const t = r.totals + const adjacent = t.adjacent_subnet + t.adjacent_domain + const parts = [ + `${t.targets} target${t.targets === 1 ? '' : 's'}`, + `${t.in_scope} in scope`, + `${t.excluded} excluded`, + `${adjacent} adjacent` + ] + if (t.unrelated > 0) parts.push(`${t.unrelated} off-list`) + return parts.join(', ') +} diff --git a/src/core/scope-monitor.ts b/src/core/scope-monitor.ts index dbfe29e1..8a99f0e3 100644 --- a/src/core/scope-monitor.ts +++ b/src/core/scope-monitor.ts @@ -1,24 +1,26 @@ import { insertEvent } from './db/events' import { eventBus } from './event-bus' +import type { ScopeVerdict } from './artifact-pin' +import { buildSuffixSet, DEFAULT_SUFFIXES, getRegistrableDomain } from './public-suffix' +import { ipInCIDR, ipFamily, isIPLiteral, V6_PROXIMITY_BITS } from './ip-match' interface ScopeConfig { - /** When a command's target is out-of-scope AND shares a root domain with a - * scope target, raise a violation event + red badge. Excluded targets always - * raise regardless. False = silent (no badge, no event, no alert). */ - warnOnViolation: boolean + /** How far down the distance ladder alerts fire (`ALERT-ROLES.md` C.3). The + * ladder is ORDERED, so the control is a floor, not N independent booleans — + * those would let an operator construct incoherent states ("warn on + * unrelated but not on adjacent"). D1 is absent from every "off" position by + * construction, which makes the fact-tier rule structural instead of + * something each caller has to remember. */ + alertFloor: AlertFloor targets: string[] excludeTargets: string[] -} - -function ipToLong(ip: string): number { - return ip.split('.').reduce((acc, o) => (acc << 8) + parseInt(o), 0) >>> 0 -} - -function matchesCIDR(ip: string, cidr: string): boolean { - if (!cidr.includes('/')) return ip === cidr - const [net, bits] = cidr.split('/') - const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0 - return (ipToLong(ip) & mask) === (ipToLong(net) & mask) + /** Container width for deriving a single-IP scope entry's D2 zone. A scope of + * `192.168.1.10` makes `192.168.1.0/24` adjacent at the default 24. */ + proximityBits: number + /** Extra multi-label public suffixes on top of the built-in table, so an + * engagement on a suffix RedLog has not heard of is not blocked on a release + * (`public-suffix.ts`). Additive only — the built-ins cannot be removed. */ + publicSuffixes: string[] } function matchesDomain(host: string, pattern: string): boolean { @@ -28,86 +30,233 @@ function matchesDomain(host: string, pattern: string): boolean { return host === pattern } -function getRootDomain(host: string): string { - const parts = host.split('.') - if (parts.length <= 2) return host - return parts.slice(-2).join('.') -} - -function extractRootDomains(targets: string[]): Set { +/** G-B2: was `parts.slice(-2)`, which made `co.uk` the registrable domain of + * `shop.example.co.uk` — so a scope of `*.example.co.uk` marked every `.co.uk` + * host as adjacent. Same for `github.io` and `s3.amazonaws.com`. */ +function extractRootDomains(targets: string[], suffixes: Set): Set { const roots = new Set() for (const t of targets) { - if (IP_RE.test(t) || t.includes('/')) continue + if (isIPLiteral(t) || t.includes('/')) continue const domain = t.startsWith('*.') ? t.slice(2) : t - roots.add(getRootDomain(domain)) + roots.add(getRegistrableDomain(domain, suffixes)) } return roots } -const IP_RE = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/ +const DEFAULT_PROXIMITY_BITS = 24 + +/** The distance of a target from declared intent (`ALERT-ROLES.md` Part B.1). + * D1 `excluded` outranks D0 `in_scope`: an explicit exclusion inside a broad + * CIDR must still fire. D2 is inferred; D3 is counted, never emitted. */ +export type ScopeDistance = + | 'in_scope' // D0 + | 'excluded' // D1 — fact + | 'adjacent_subnet' // D2 — inferred + | 'adjacent_domain' // D2 — inferred + | 'unrelated' // D3 + +/** The closed `reason` vocabulary (G-B4) as a type. D3 never produces a + * violation, so it has no entry. */ +export type ViolationReason = 'excluded_target' | 'adjacent_subnet' | 'adjacent_domain' | 'unrelated' + +/** D1 only · D1+D2 (default) · D1+D2+D3. `all` is today's ACCIDENTAL behaviour + * on the pre-G-B3 IP path made into a deliberate, named choice — strict + * authorisation where any target not on the list belongs on the record. */ +export type AlertFloor = 'excluded_only' | 'adjacent' | 'all' + +export interface ScopeViolation { + target: string + command: string + timestamp: number + reason: ViolationReason + /** §3 tier (K1). `excluded_target` is an observed rule match; both + * `adjacent_*` are proximity judgements. */ + authority: 'fact' | 'inferred' +} + +function normaliseBits(bits: number | undefined): number { + return Number.isInteger(bits) && (bits as number) >= 1 && (bits as number) <= 32 + ? (bits as number) + : DEFAULT_PROXIMITY_BITS +} + +/** The container rule, IP side: **a single point expands one level into its + * container; an entry that already IS a container does not expand.** A bare + * `192.168.1.10` carries no boundary information, so deriving a /24 fills in a + * missing boundary. A written `10.0.0.0/8` states one — widening it would + * invent authorisation the operator did not give. */ +function isAdjacentSubnet(ip: string, targets: string[], v4Bits: number): boolean { + return targets.some((t) => { + if (t.includes('/')) return false // a stated boundary never widens + const family = ipFamily(t) + if (family === null) return false + return ipInCIDR(ip, `${t}/${family === 6 ? V6_PROXIMITY_BITS : v4Bits}`) + }) +} + +/** The container rule, domain side. Note the deliberate asymmetry with IPs: + * a wildcard `*.staging.example.com` DOES still expand to its registrable + * domain, because that domain is the *ownership* boundary the authorisation is + * actually about — hitting `prod.example.com` while scoped to staging is the + * exact mistake D2 exists to catch. A CIDR has no comparable ownership + * boundary recoverable from the string, which is why it does not expand. */ +function isAdjacentDomain(host: string, roots: Set, suffixes: Set): boolean { + return roots.has(getRegistrableDomain(host, suffixes)) +} + +/** Pure distance classification — the test seam for the ladder, sibling of + * `classifyIP()` in `ip-monitor.ts`. No side effects, unlike `checkTarget`. */ +export function classifyDistance( + target: string, + cfg: { + targets: string[] + excludeTargets: string[] + proximityBits?: number + /** Pass a prebuilt set to avoid rebuilding it per call; `publicSuffixes` + * is the convenience form for callers holding raw config. */ + suffixes?: Set + publicSuffixes?: string[] + } +): ScopeDistance { + if (cfg.targets.length === 0) return 'in_scope' + const isIP = isIPLiteral(target) + const hits = (list: string[]): boolean => + list.some((t) => (isIP ? ipInCIDR(target, t) : matchesDomain(target, t))) + + if (hits(cfg.excludeTargets)) return 'excluded' + if (hits(cfg.targets)) return 'in_scope' + + // D2 — near-miss. Before G-B1/G-B3 the IP branch had no container rule at all + // and every out-of-scope IP raised, so scanning 8.8.8.8 alerted exactly as + // loudly as hitting the wrong box on the target segment. A muted channel is a + // removed defence, so that noise was a correctness bug, not an annoyance. + if (isIP) { + return isAdjacentSubnet(target, cfg.targets, normaliseBits(cfg.proximityBits)) + ? 'adjacent_subnet' + : 'unrelated' + } + const suffixes = cfg.suffixes ?? (cfg.publicSuffixes?.length ? buildSuffixSet(cfg.publicSuffixes) : DEFAULT_SUFFIXES) + return isAdjacentDomain(target, extractRootDomains(cfg.targets, suffixes), suffixes) + ? 'adjacent_domain' + : 'unrelated' +} + +/** Pure scope classification — no side effects, unlike `checkTarget` which + * records violations. This is the classifier scope-aware sanitize and retention + * share (SPEC-SCOPE-AWARE-LIFECYCLE.md Part A). An empty/absent target is + * `unknown` (the caller decides the safe default per action); no scope set at + * all means everything is in scope. */ +export function classifyTarget( + target: string | null | undefined, + cfg: { targets: string[]; excludeTargets: string[] } +): ScopeVerdict { + if (!target) return 'unknown' + if (cfg.targets.length === 0) return 'in_scope' + const isIP = isIPLiteral(target) + const isExcluded = cfg.excludeTargets.some((ex) => (isIP ? ipInCIDR(target, ex) : matchesDomain(target, ex))) + if (isExcluded) return 'excluded' + const isInScope = cfg.targets.some((t) => (isIP ? ipInCIDR(target, t) : matchesDomain(target, t))) + return isInScope ? 'in_scope' : 'out_of_scope' +} export class ScopeMonitor { - private config: ScopeConfig = { warnOnViolation: true, targets: [], excludeTargets: [] } + private config: ScopeConfig = { + alertFloor: 'adjacent', + targets: [], + excludeTargets: [], + proximityBits: DEFAULT_PROXIMITY_BITS, + publicSuffixes: [] + } + /** Built once per configure(), not per command — the table is ~250 entries. */ + private suffixes: Set = DEFAULT_SUFFIXES private engagementId = 'default' private operatorId = '' - private violations: Array<{ target: string; command: string; timestamp: number }> = [] - private rootDomains: Set = new Set() + /** G-C1: the record carries WHICH rung of the ladder fired. Without it the UI + * could only count violations, so a D1 explicit-exclusion hit and a D2 + * proximity inference rendered as the same red — the distinction G-B4 put in + * the data and G-C2 put on the wire stopped at the operator's eye. */ + private violations: ScopeViolation[] = [] + /** D3 is silent, not dropped: the count is what lets the operator (and the + * adherence report, G-D1) see how much was suppressed. Without it, "silent" + * is indistinguishable from "not looking". */ + private unrelated = 0 configure(opts: { - warnOnViolation?: boolean + alertFloor?: AlertFloor targets?: string[] excludeTargets?: string[] + proximityBits?: number + publicSuffixes?: string[] engagementId?: string operatorId?: string }): void { - if (opts.warnOnViolation !== undefined) this.config.warnOnViolation = opts.warnOnViolation + if (opts.alertFloor !== undefined) this.config.alertFloor = opts.alertFloor if (opts.targets) this.config.targets = opts.targets if (opts.excludeTargets) this.config.excludeTargets = opts.excludeTargets + if (opts.proximityBits !== undefined) this.config.proximityBits = normaliseBits(opts.proximityBits) + if (opts.publicSuffixes) this.config.publicSuffixes = opts.publicSuffixes + this.suffixes = this.config.publicSuffixes.length + ? buildSuffixSet(this.config.publicSuffixes) + : DEFAULT_SUFFIXES if (opts.engagementId) this.engagementId = opts.engagementId if (opts.operatorId) this.operatorId = opts.operatorId - this.rootDomains = extractRootDomains(this.config.targets) } + /** Walk the distance ladder (`ALERT-ROLES.md` Part B). RedLog never blocks — + * the return value tells the caller what happened, it does not gate anything. + * Note that a D3 target still lands in the timeline as `detectedTarget` like + * any other command; only the *alert* is suppressed, never the record. */ checkTarget(target: string, command: string): { inScope: boolean; violation: boolean } { if (this.config.targets.length === 0) return { inScope: true, violation: false } - const isExcluded = this.config.excludeTargets.some((ex) => - IP_RE.test(target) ? matchesCIDR(target, ex) : matchesDomain(target, ex) - ) - if (isExcluded) { - this.recordViolation(target, command, 'excluded_target') - return { inScope: false, violation: true } - } + const distance = classifyDistance(target, { ...this.config, suffixes: this.suffixes }) - const isInScope = this.config.targets.some((t) => - IP_RE.test(target) ? matchesCIDR(target, t) : matchesDomain(target, t) - ) + if (distance === 'in_scope') return { inScope: true, violation: false } - if (isInScope) return { inScope: true, violation: false } - - // Not in scope — only warn if the target shares a root domain with a scope target. - // Completely unrelated hosts (e.g. google.com when scope is *.example.com) are ignored. - const isIP = IP_RE.test(target) - if (!isIP) { - const targetRoot = getRootDomain(target) - if (!this.rootDomains.has(targetRoot)) { - return { inScope: false, violation: false } - } + // D1 — fact tier. Fires regardless of warnOnViolation: if you explicitly + // told RedLog to keep off X, hitting X is not a preference call. + if (distance === 'excluded') { + this.recordViolation(target, command, 'excluded_target', 'fact') + return { inScope: false, violation: true } } - // Warnings off = silent. No badge, no event, no deconfliction alert. The raw - // shell command still lands in the timeline like any other command — RedLog - // never blocks. (Excluded targets above always warn regardless — if you - // explicitly told RedLog to keep off X, hitting X is not a preference call.) - if (!this.config.warnOnViolation) { - return { inScope: false, violation: false } + // D3 — always counted; emitted only under the strictest floor. Note the + // count is kept either way: "silent" must stay distinguishable from "not + // looking", and the adherence report (G-D1) needs the denominator. + if (distance === 'unrelated') { + this.unrelated += 1 + if (this.config.alertFloor !== 'all') return { inScope: false, violation: false } + // A non-match against a stated list is an OBSERVATION — unlike D2 there + // is no proximity heuristic involved, so this is fact tier. + this.recordViolation(target, command, 'unrelated', 'fact') + return { inScope: false, violation: true } } - this.recordViolation(target, command, 'out_of_scope') + + // D2 — inferred tier, and therefore silenceable. Floored out = no badge, no + // event, no deconfliction alert. The raw shell command still lands in the + // timeline like any other command — RedLog never blocks. + if (this.config.alertFloor === 'excluded_only') return { inScope: false, violation: false } + this.recordViolation(target, command, distance, 'inferred') return { inScope: false, violation: true } } - private recordViolation(target: string, command: string, reason: string): void { - this.violations.push({ target, command, timestamp: Date.now() }) + getUnrelatedCount(): number { + return this.unrelated + } + + /** `reason` is a closed vocabulary (G-B4) so downstream can tell a fact from an + * inference: before this, both the domain-adjacent case and the unrelated-IP + * case wrote `out_of_scope` and were indistinguishable. `authority` is the + * data half of the §3 fact/inferred split — it is what lets the deconfliction + * feed forward D1 to the blue team while holding D2 back (G-C2), instead of + * today's all-or-nothing `subtypes: ['scope_violation']`. */ + private recordViolation( + target: string, + command: string, + reason: ViolationReason, + authority: 'fact' | 'inferred' + ): void { + this.violations.push({ target, command, timestamp: Date.now(), reason, authority }) if (!this.operatorId) return try { @@ -115,13 +264,14 @@ export class ScopeMonitor { subtype: 'scope_violation', target, command: command.slice(0, 200), - reason + reason, + authority }, { engagementId: this.engagementId, operatorId: this.operatorId, targetId: target }) if (evt) eventBus.publish(evt) } catch { /* DB may not be ready */ } } - getViolations(): Array<{ target: string; command: string; timestamp: number }> { + getViolations(): ScopeViolation[] { return [...this.violations] } diff --git a/src/core/scope-sanitize-plan.ts b/src/core/scope-sanitize-plan.ts new file mode 100644 index 00000000..84ede95e --- /dev/null +++ b/src/core/scope-sanitize-plan.ts @@ -0,0 +1,104 @@ +// Scope-aware sanitize planner (SPEC-SCOPE-AWARE-LIFECYCLE.md Part B, G3). Pure: +// given candidate events and a pure scope classifier, decide which events' +// bodies to sanitize for a client-deliverable export. The actual byte +// replacement reuses sanitize.ts (inline fields) + the io sidecar; this function +// only produces the PLAN (a dry-run), which the operator confirms. +// +// Two safety rules the spec is emphatic about: +// - `unknown`-target events are NEVER auto-sanitized — sanitize removes content +// from a *reviewable copy*, so an absent verdict surfaces for operator +// decision (default unchecked), never a silent strip (A2). +// - The plan MUST cover the event's io_ref sidecar body, not just inline +// fields — an out-of-scope response body lives in `io/.bin` and would +// leak through the side door otherwise (A1, §3 "critical"). + +import type { ScopeVerdict } from './artifact-pin' + +/** Minimal event shape the planner needs — decoupled from the DB row type. */ +export interface SanitizeCandidate { + id: string + targetId?: string | null + data: Record +} + +export type SanitizeAction = 'sanitize' | 'flag_unknown' | 'keep' + +export interface ScopeSanitizeItem { + eventId: string + target: string | null + scope: ScopeVerdict + action: SanitizeAction + /** Inline body fields present on the event, proposed for sanitization. */ + fields: string[] + /** io sidecar body digests referenced by the event (must be sanitized too). */ + ioRefs: string[] +} + +export interface ScopeSanitizePlan { + items: ScopeSanitizeItem[] + /** action === 'sanitize' — out-of-scope / excluded, auto-checked. */ + toSanitize: ScopeSanitizeItem[] + /** action === 'flag_unknown' — surfaced for operator decision, default off. */ + unknown: ScopeSanitizeItem[] + /** count of in-scope events left untouched. */ + keptInScope: number +} + +// Body/content fields that can carry out-of-scope bytes. Deliberately excludes +// `command` and target metadata: the *fact* a host was touched (and by what +// action) must survive in a client deliverable — only the captured content of +// the out-of-scope exchange is stripped (A1: the scope_violation + host remain). +const BODY_FIELDS = ['response_preview', 'request_body_preview', 'output', 'output_preview', 'stdout', 'stderr'] as const + +/** Resolve the host/target a candidate event is about, for scope classification. + * Mirrors the extraction the capture path uses; null when none is present. */ +export function resolveEventTarget(c: SanitizeCandidate): string | null { + const d = c.data + const direct = c.targetId || (d.dest_host as string) || (d.host as string) || (d.detectedTarget as string) + if (direct) return direct + const url = d.url as string | undefined + if (typeof url === 'string') { + try { return new URL(url).hostname || null } catch { /* not a full URL */ } + } + return null +} + +function ioRefsOf(data: Record): string[] { + const io = data.io as { request?: { ref?: unknown }; response?: { ref?: unknown } } | undefined + if (!io) return [] + const out: string[] = [] + for (const slot of ['request', 'response'] as const) { + const ref = io[slot]?.ref + if (typeof ref === 'string') out.push(ref) + } + return out +} + +export function planScopeSanitize( + events: SanitizeCandidate[], + classify: (target: string | null) => ScopeVerdict +): ScopeSanitizePlan { + const items: ScopeSanitizeItem[] = [] + for (const e of events) { + const target = resolveEventTarget(e) + const scope = classify(target) + const fields = BODY_FIELDS.filter((f) => typeof e.data[f] === 'string' && (e.data[f] as string).length > 0) + const ioRefs = ioRefsOf(e.data) + // Nothing to strip on this event → don't list it at all (keeps the preview + // focused on events that actually carry content). + if (fields.length === 0 && ioRefs.length === 0) continue + + let action: SanitizeAction + if (scope === 'out_of_scope' || scope === 'excluded') action = 'sanitize' + else if (scope === 'unknown') action = 'flag_unknown' + else action = 'keep' // in_scope + + items.push({ eventId: e.id, target, scope, action, fields, ioRefs }) + } + return { + items, + toSanitize: items.filter((i) => i.action === 'sanitize'), + unknown: items.filter((i) => i.action === 'flag_unknown'), + keptInScope: items.filter((i) => i.action === 'keep').length + } +} diff --git a/src/core/scope-sanitize.ts b/src/core/scope-sanitize.ts new file mode 100644 index 00000000..e6e7556d --- /dev/null +++ b/src/core/scope-sanitize.ts @@ -0,0 +1,133 @@ +import crypto from 'crypto' +import { getDB } from './db/index' +import { insertEvent } from './db/events' +import { eventBus } from './event-bus' +import { planScopeSanitize, type SanitizeCandidate, type ScopeSanitizePlan } from './scope-sanitize-plan' +import type { ScopeVerdict } from './artifact-pin' + +// Scope-aware sanitize EXECUTION (SPEC-SCOPE-AWARE-LIFECYCLE.md Part B, G3). The +// planner (scope-sanitize-plan.ts) decides WHAT; this applies it, reusing the +// layer-4 sanitize storage model (docs/redaction-design.md): never mutate the +// source event, write replacement bytes to a side table (sanitized_events for +// inline fields, sanitized_io for sidecar bodies), and APPEND one chained +// system.sanitized recording every swap. The bundle export serves the +// replacements; redlog-verify reads the swap and confirms bytes hash to the +// replacement digest → *sanitized*, never *tampered*. + +const sha256 = (s: string): string => crypto.createHash('sha256').update(s).digest('hex') + +/** The redacted body a client-deliverable export shows in place of out-of-scope + * content. Pure. Keeps the touched host visible (A1: the fact a host was hit + * survives) while removing the captured bytes. */ +export function scopeRedactionPlaceholder(verdict: ScopeVerdict, target: string | null): string { + const where = target ? ` — ${target}` : '' + switch (verdict) { + case 'excluded': return `[redacted: excluded target${where}]` + case 'out_of_scope': return `[redacted: out-of-scope${where}]` + case 'unknown': return `[redacted: unclassified target${where}]` + case 'in_scope': return '' // never sanitized + } +} + +export interface RunScopeSanitizeInput { + events: SanitizeCandidate[] + classify: (target: string | null) => ScopeVerdict + operatorId: string + engagementId: string + /** Preview only — compute the plan, write nothing. */ + dryRun?: boolean + /** Operator opted to also sanitize `unknown`-target events (default: don't — + * A2, unknown is never auto-stripped). */ + includeUnknown?: boolean +} + +export interface RunScopeSanitizeResult { + plan: ScopeSanitizePlan + dryRun: boolean + sanitizedFields: number + sanitizedIoBodies: number + sanitizedEventId: string | null +} + +interface FieldRow { source_event_id: string; field: string; sanitized_value: string; replacement_sha256: string } +interface IoRow { orig_sha256: string; sanitized_value: string; replacement_sha256: string } + +export function runScopeSanitize(input: RunScopeSanitizeInput): RunScopeSanitizeResult { + const plan = planScopeSanitize(input.events, input.classify) + const items = [...plan.toSanitize, ...(input.includeUnknown ? plan.unknown : [])] + + // A deduped io body may be cited by an out-of-scope AND an in-scope event. + // Sanitizing removes the single file, so a body referenced by ANY kept event + // must not be sanitized (the refcount rule, mirrored from prune). Collect the + // refs the kept items still need and exclude them. + const keptRefs = new Set() + const keptItems = plan.items.filter((i) => !items.includes(i)) + for (const i of keptItems) for (const r of i.ioRefs) keptRefs.add(r) + + const fieldRows: FieldRow[] = [] + const ioRows: IoRow[] = [] + const seenIo = new Set() + for (const item of items) { + const placeholder = scopeRedactionPlaceholder(item.scope, item.target) + for (const field of item.fields) { + fieldRows.push({ source_event_id: item.eventId, field, sanitized_value: placeholder, replacement_sha256: sha256(placeholder) }) + } + for (const ref of item.ioRefs) { + if (seenIo.has(ref) || keptRefs.has(ref)) continue // sanitize a body once, never if still needed in-scope + seenIo.add(ref) + ioRows.push({ orig_sha256: ref, sanitized_value: placeholder, replacement_sha256: sha256(placeholder) }) + } + } + + if (input.dryRun || (fieldRows.length === 0 && ioRows.length === 0)) { + return { plan, dryRun: true, sanitizedFields: 0, sanitizedIoBodies: 0, sanitizedEventId: null } + } + + // One chained system.sanitized covers the whole scope pass. `io_replacements` + // is what verify keys on to treat swapped io bodies as sanitized-not-tampered. + const now = Date.now() + const chained = insertEvent('system', { + subtype: 'sanitized', + reason: 'scope', + source_events: [...new Set(items.map((i) => i.eventId))], + fields: [...new Set(fieldRows.map((r) => r.field))], + replacements: fieldRows.map((r) => ({ event: r.source_event_id, field: r.field, sha256: r.replacement_sha256 })), + io_replacements: ioRows.map((r) => ({ ref: r.orig_sha256, sha256: r.replacement_sha256 })) + }, { engagementId: input.engagementId, operatorId: input.operatorId }) + if (!chained) throw new Error('Failed to append system.sanitized event') + eventBus.publish(chained) + + const db = getDB() + const fieldStmt = db.prepare( + `INSERT OR REPLACE INTO sanitized_events + (source_event_id, field, sanitized_value, replacement_sha256, created_at, sanitized_event_id) + VALUES (?, ?, ?, ?, ?, ?)` + ) + const ioStmt = db.prepare( + `INSERT OR REPLACE INTO sanitized_io + (orig_sha256, sanitized_value, replacement_sha256, created_at, sanitized_event_id) + VALUES (?, ?, ?, ?, ?)` + ) + db.transaction(() => { + for (const r of fieldRows) fieldStmt.run(r.source_event_id, r.field, r.sanitized_value, r.replacement_sha256, now, chained.id) + for (const r of ioRows) ioStmt.run(r.orig_sha256, r.sanitized_value, r.replacement_sha256, now, chained.id) + })() + + return { + plan, + dryRun: false, + sanitizedFields: fieldRows.length, + sanitizedIoBodies: ioRows.length, + sanitizedEventId: chained.id + } +} + +/** All sanitized io replacements, keyed by original digest — for the bundle + * export to serve the redacted body under `io/.bin`. */ +export function getSanitizedIo(): Map { + const db = getDB() + const rows = db.prepare('SELECT orig_sha256, sanitized_value, replacement_sha256 FROM sanitized_io').all() as Array<{ orig_sha256: string; sanitized_value: string; replacement_sha256: string }> + const out = new Map() + for (const r of rows) out.set(r.orig_sha256, { value: r.sanitized_value, replacementSha: r.replacement_sha256 }) + return out +} diff --git a/src/main/index.ts b/src/main/index.ts index f5036b7e..49593138 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4,9 +4,9 @@ import path from 'path' import { homedir } from 'os' import { createMainWindow, createOverlayWindow } from './windows' import { createTray, setTrayRecording } from './tray' -import { IPMonitor, IPStatus } from '../core/ip-monitor' +import { IPMonitor, IPStatus, verdictAuthority } from '../core/ip-monitor' import yaml from 'js-yaml' -import { loadConfig, saveConfig, loadScopeFile, RedLogConfig } from '../core/config' +import { loadConfig, saveConfig, loadScopeFile, readScopeFile, RedLogConfig, type ScopeProvenance } from '../core/config' import { initDB, closeDB, getProjectDir } from '../core/db/index' import { insertEvent, queryEvents, queryEventById, getEventCount, searchEvents, queryScopeFilteredEvents, type RedLogEvent } from '../core/db/events' import { @@ -17,6 +17,7 @@ import fs from 'fs' import { eventBus } from '../core/event-bus' import { ScreenshotAgent } from './services/screenshot-agent' import { ScopeMonitor } from '../core/scope-monitor' +import { buildAdherenceReport, summariseAdherence, type ScopeConfigSnapshot } from '../core/scope-adherence' import { LootDetector } from '../core/loot-detector' import { getChainLength } from '../core/evidence-chain' import { anchorNow, listAnchors, startAnchorLoop, stopAnchorLoop, verifyLatestAnchor, verifyChainFullAsync, upgradeAnchor, upgradeAllPending, verifyRandomSample } from '../core/chain-anchor' @@ -24,6 +25,8 @@ import { startNtpLoop, stopNtpLoop, getNtpOffsetMs, getLastNtpQuery } from '../c import { configureRedaction } from '../core/redaction' import { exportBundle } from '../core/bundle-export' import { sweepRetention } from '../core/retention' +import { classifyTarget } from '../core/scope-monitor' +import type { ScopeVerdict } from '../core/artifact-pin' import { configureDeconfliction, getDeconflictionConfig, notifyDeconfliction, testWebhook, flushDeconflictionOnShutdown } from '../core/deconfliction' import { listProjects, createProject, openProject, deleteProject, renameProject, @@ -51,9 +54,10 @@ import { setTailerContributionSink, type TailerLike } from '../core/plugins/tail import { registerAdapter as registerTailerAdapter, unregisterAdapter as unregisterTailerAdapter, type TailerAdapter } from './services/tailer-host' import { getCaptureHealth, invalidateHooksCache, noteSampleBroken, noteSampleOk, clearSampleBroken, configureCaptureHealth, noteDbError } from '../core/capture-health' import { launchBrowser, stopBrowser, isBrowserRunning, detectBrowser, DEFAULT_BROWSER } from './services/browser-launcher' -import { detectLink } from './services/network-info' +import { detectLink, applyWifiNamePolicy } from './services/network-info' import { checkForUpdates } from './services/updater' import { isInsideDir } from '../core/paths' +import { readBody as readIoBody, resolveRef as resolveIoRef, MAX_IO_READ_BYTES } from '../core/io-store' // Windows text rendering: DirectComposition improves font clarity on low-DPI // screens; DirectWrite uses the native font rasterizer for crisper CJK glyphs. @@ -278,12 +282,26 @@ function applyDock(): void { if (keepDockIcon) app.dock?.show() else app.dock?.hide() } +// `network.showWifiName` — off by default; see applyWifiNamePolicy for why the +// SSID is treated as a disclosure rather than a display preference. +let showWifiName = false function startLinkMonitor(): void { - const refresh = (): void => { detectLink().then((l) => { currentLink = l }).catch(() => {}) } + const refresh = (): void => { + detectLink().then((l) => { currentLink = applyWifiNamePolicy(l, showWifiName) }).catch(() => {}) + } refresh() if (linkTimer) clearInterval(linkTimer) linkTimer = setInterval(refresh, 20_000) } +function setShowWifiName(on: boolean): void { + const was = showWifiName + showWifiName = on + // Turning it off must take the name off the HUD NOW, not at the next 20s + // poll — the operator flipping this switch is usually about to share a screen. + if (!on) currentLink = applyWifiNamePolicy(currentLink, false) + // Turning it on has to re-probe: the cached link no longer carries a name. + else if (!was) startLinkMonitor() +} // Last broadcast state, kept module-level to detect transitions. A change in // ipSafety (safe/exposed/unknown) or in the externalIP itself is an OPSEC signal @@ -291,6 +309,8 @@ function startLinkMonitor(): void { // current state was ever in the HUD; now every transition lands in the audit log. let lastBroadcastSafety: IPStatus['ipSafety'] | null = null let lastBroadcastIP: string | null = null +let lastBroadcastLanSafety: IPStatus['lanSafety'] | null = null +let lastBroadcastInternalIP: string | null = null function broadcastIPStatus(status: IPStatus): void { const s = { ...status, link: currentLink } send(mainWindow, 'ip:status', s) @@ -300,22 +320,97 @@ function broadcastIPStatus(status: IPStatus): void { if (lastBroadcastSafety !== null && currentEngagementId && currentOperatorId) { const safetyChanged = status.ipSafety !== lastBroadcastSafety const ipChanged = status.externalIP != null && status.externalIP !== lastBroadcastIP - if (safetyChanged || ipChanged) { + // G-A4: moving between internal segments mid-engagement is the same class + // of drift signal as the egress changing — a reassociation to a guest SSID + // can leave the external IP untouched while putting you on the wrong network. + const lanSafetyChanged = status.lanSafety !== lastBroadcastLanSafety + const internalChanged = status.internalIP != null && status.internalIP !== lastBroadcastInternalIP + if (safetyChanged || ipChanged || lanSafetyChanged || internalChanged) { try { + // K1 split-authority, the same shape as `scope_violation`: a transition + // INTO `presumed_safe` records an inference ("not obviously you"), + // every other verdict records an observed rule match. No type-level + // default can be right for both, so the emitter stamps it per event. + const tier = verdictAuthority(status.ipSafety) const ev = insertEvent('system', { subtype: 'ip_transition', from_safety: lastBroadcastSafety, to_safety: status.ipSafety, + ...(lanSafetyChanged || internalChanged + ? { + from_lan_safety: lastBroadcastLanSafety, to_lan_safety: status.lanSafety, + from_internal_ip: lastBroadcastInternalIP, to_internal_ip: status.internalIP ?? null + } + : {}), + ...(tier ? { authority: tier } : {}), from_ip: lastBroadcastIP, to_ip: status.externalIP ?? null, description: safetyChanged ? `IP safety ${lastBroadcastSafety} → ${status.ipSafety}${ipChanged ? ` (${lastBroadcastIP ?? '?'} → ${status.externalIP})` : ''}` - : `External IP changed ${lastBroadcastIP} → ${status.externalIP}` + : ipChanged + ? `External IP changed ${lastBroadcastIP} → ${status.externalIP}` + : lanSafetyChanged + ? `LAN profile ${lastBroadcastLanSafety} → ${status.lanSafety} (${lastBroadcastInternalIP ?? '?'} → ${status.internalIP})` + : `Internal IP changed ${lastBroadcastInternalIP} → ${status.internalIP}` }, { engagementId: currentEngagementId, operatorId: currentOperatorId }) if (ev) eventBus.publish(ev) } catch { /* audit-additive; never block IP broadcasting */ } } } lastBroadcastSafety = status.ipSafety + lastBroadcastLanSafety = status.lanSafety if (status.externalIP != null) lastBroadcastIP = status.externalIP + if (status.internalIP != null) lastBroadcastInternalIP = status.internalIP +} + +// G-D2: record WHICH document the scope came from, chained. `scopeFile` used to +// hold a path and nothing else, so a reviewer reading an adherence report had no +// way to check the scope it was judged against against the authorisation +// document they were given. The digest is the join between the two. +// +// Emitted only at the two AUTHORITATIVE load points (project open, config save) +// — the read-only re-reads for export must not manufacture history. Deduped on +// digest so reopening a project does not fill the timeline with identical rows. +let lastScopeDigest: string | null = null +function recordScopeProvenance(p: ScopeProvenance): void { + if (!currentEngagementId || !currentOperatorId) return + if (p.digest && p.digest === lastScopeDigest) return + lastScopeDigest = p.digest || null + try { + const ev = insertEvent('system', { + subtype: 'scope_loaded', + path: p.path, + digest: p.digest, + bytes: p.bytes, + entries: p.entries, + modified_at: p.modifiedAt, + ...(p.error ? { error: p.error } : {}), + description: p.error + ? `Scope file could not be read: ${p.path} — ${p.error}` + : `Scope loaded from ${p.path}: ${p.entries} entr${p.entries === 1 ? 'y' : 'ies'}, sha256 ${p.digest.slice(0, 12)}…` + }, { engagementId: currentEngagementId, operatorId: currentOperatorId }) + if (ev) eventBus.publish(ev) + } catch { /* additive — never block project open */ } +} + +// The scope as the classifier sees it, plus where it came from (G-D2). Shared +// so the live summary, the loose export and the signed bundle cannot disagree +// about what scope an engagement was judged against. Read-only: it deliberately +// does NOT call `recordScopeProvenance` — an export must not write history. +function currentScopeSnapshot(cfg: RedLogConfig): ScopeConfigSnapshot { + let targets = cfg.scope.targets + let provenance: ScopeProvenance | null = null + if (cfg.scope.scopeFile) { + const read = readScopeFile(cfg.scope.scopeFile) + if (read.targets.length > 0) targets = [...targets, ...read.targets] + provenance = read.provenance + } + return { + targets, + excludeTargets: cfg.scope.excludeTargets, + proximityBits: cfg.scope.proximityBits, + publicSuffixes: cfg.scope.publicSuffixes, + alertFloor: cfg.scope.alertFloor, + provenance + } } // Log the security-relevant fields that changed on config:save. Cosmetic changes @@ -327,12 +422,15 @@ function logConfigDiff(oldCfg: RedLogConfig, newCfg: RedLogConfig): void { const check = (path: string, from: unknown, to: unknown): void => { if (JSON.stringify(from) !== JSON.stringify(to)) changed[path] = { from, to } } - check('scope.warnOnViolation', oldCfg.scope?.warnOnViolation, newCfg.scope?.warnOnViolation) + check('scope.alertFloor', oldCfg.scope?.alertFloor, newCfg.scope?.alertFloor) check('scope.targets', oldCfg.scope?.targets, newCfg.scope?.targets) check('scope.excludeTargets', oldCfg.scope?.excludeTargets, newCfg.scope?.excludeTargets) + check('scope.proximityBits', oldCfg.scope?.proximityBits, newCfg.scope?.proximityBits) + check('scope.publicSuffixes', oldCfg.scope?.publicSuffixes, newCfg.scope?.publicSuffixes) check('scope.scopeFile', oldCfg.scope?.scopeFile, newCfg.scope?.scopeFile) check('network.blacklist', oldCfg.network?.blacklist, newCfg.network?.blacklist) check('network.whitelist', oldCfg.network?.whitelist, newCfg.network?.whitelist) + check('network.lanProfile', oldCfg.network?.lanProfile, newCfg.network?.lanProfile) check('engagement.id', oldCfg.engagement?.id, newCfg.engagement?.id) check('operator.id', oldCfg.operator?.id, newCfg.operator?.id) check('operator.name', oldCfg.operator?.name, newCfg.operator?.name) @@ -391,9 +489,11 @@ function startProject(project: ProjectMeta): void { ipMonitor.configure({ whitelist: config.network.whitelist, blacklist: config.network.blacklist, + lanProfile: config.network.lanProfile, checkInterval: config.network.checkInterval, providers: config.network.providers, confirmations: config.network.confirmations, + staleAfter: config.network.staleAfter, ipMode: config.network.ipMode }) screenshotAgent.configure({ @@ -405,13 +505,16 @@ function startProject(project: ProjectMeta): void { let scopeTargets = config.scope.targets if (config.scope.scopeFile) { - const loaded = loadScopeFile(config.scope.scopeFile) + const { targets: loaded, provenance } = readScopeFile(config.scope.scopeFile) if (loaded.length > 0) scopeTargets = [...scopeTargets, ...loaded] + recordScopeProvenance(provenance) } scopeMonitor.configure({ - warnOnViolation: config.scope.warnOnViolation !== false, + alertFloor: config.scope.alertFloor, targets: scopeTargets, excludeTargets: config.scope.excludeTargets, + proximityBits: config.scope.proximityBits, + publicSuffixes: config.scope.publicSuffixes, engagementId, operatorId }) @@ -473,6 +576,7 @@ function startProject(project: ProjectMeta): void { } catch (e) { console.error('[plugins] init failed:', e) } configureDeconfliction(config.deconfliction) setVpnAdapters(config.network.vpnAdapters) + setShowWifiName(config.network.showWifiName === true) configureTerminal({ engagementId, operatorId, maxCastBytes: config.terminal?.maxCastBytes }) // v0.9.6 (T2): core/ can't import main/, so hand the live cast position in. @@ -492,9 +596,14 @@ function startProject(project: ProjectMeta): void { // castKeepDays / screenshots.keepDays silently did nothing and the // cast_pruned / screenshot_pruned audit events were never written. Unit // tests missed it because they import core/retention directly. - const swept = sweepRetention(config, { engagementId, operatorId }) - if (swept.cast > 0 || swept.screenshots > 0) { - console.log(`[retention] pruned ${swept.cast} .cast file(s) + ${swept.screenshots} screenshot(s)`) + // Feed a pure scope classifier so io lifecycle GC pins in-scope bodies and + // evicts out-of-scope ones first under size pressure (SPEC-SCOPE-AWARE- + // LIFECYCLE.md scope-as-pin). Pure — no violation side effects. + const resolveScope = (target: string): ScopeVerdict => + classifyTarget(target, { targets: scopeTargets, excludeTargets: config.scope.excludeTargets }) + const swept = sweepRetention(config, { engagementId, operatorId, resolveScope }) + if (swept.cast > 0 || swept.screenshots > 0 || swept.io > 0 || swept.ioCompressed > 0) { + console.log(`[retention] pruned ${swept.cast} .cast + ${swept.screenshots} screenshot(s) + ${swept.io} io body(ies); compressed ${swept.ioCompressed} io body(ies)`) } } catch (e) { console.error('[retention] sweep failed:', e) } @@ -942,20 +1051,25 @@ app.whenReady().then(() => { ipMonitor.configure({ whitelist: newConfig.network.whitelist, blacklist: newConfig.network.blacklist, + lanProfile: newConfig.network.lanProfile, ipMode: newConfig.network.ipMode, checkInterval: newConfig.network.checkInterval, providers: newConfig.network.providers, - confirmations: newConfig.network.confirmations + confirmations: newConfig.network.confirmations, + staleAfter: newConfig.network.staleAfter }) let targets = newConfig.scope.targets if (newConfig.scope.scopeFile) { - const loaded = loadScopeFile(newConfig.scope.scopeFile) + const { targets: loaded, provenance } = readScopeFile(newConfig.scope.scopeFile) if (loaded.length > 0) targets = [...targets, ...loaded] + recordScopeProvenance(provenance) } scopeMonitor.configure({ - warnOnViolation: newConfig.scope.warnOnViolation !== false, + alertFloor: newConfig.scope.alertFloor, targets, - excludeTargets: newConfig.scope.excludeTargets + excludeTargets: newConfig.scope.excludeTargets, + proximityBits: newConfig.scope.proximityBits, + publicSuffixes: newConfig.scope.publicSuffixes }) screenshotAgent.configure({ quality: newConfig.screenshot.quality, @@ -987,6 +1101,7 @@ app.whenReady().then(() => { if (newConfig.redaction) configureRedaction(newConfig.redaction) if (newConfig.deconfliction) configureDeconfliction(newConfig.deconfliction) setVpnAdapters(newConfig.network.vpnAdapters) + setShowWifiName(newConfig.network.showWifiName === true) // The HUD reads its config once at mount — push overlay settings so toggling // "show Mark button" takes effect live instead of only after a restart. send(overlayWindow, 'overlay:showMark', newConfig.overlay?.showMarkButton !== false) @@ -1182,6 +1297,30 @@ app.whenReady().then(() => { } }) + // --- io_ref sidecar (SPEC-IO-SIDECAR.md) --- + // Read a full HTTP body out of /io/.bin on demand. + // Unlike the removed screenshot:read, this takes a `ref` (a sha256 stem), + // not an arbitrary filePath — `resolveRef` rejects anything that isn't a + // 64-hex digest resolving inside io/, so a compromised renderer cannot coax + // a read of any other file (A7). Returns text (bodies are text content-types) + // plus how many bytes were read and whether more remain past the cap. + ipcMain.handle('io:read', (_e, ref: string, off?: number, len?: number) => { + try { + const dir = getProjectDir() + // A well-formed ref whose file is missing reads as pruned, not tampered. + if (resolveIoRef(dir, ref) === null) return { ok: false, error: 'invalid ref' } + const buf = readIoBody(dir, ref, off, len) + if (buf === null) { + // Distinguish "too large to inline" from "gone": a valid ref with no + // range that returns null is either pruned or over the read cap. + return { ok: false, error: 'unavailable', maxBytes: MAX_IO_READ_BYTES } + } + return { ok: true, text: buf.toString('utf8'), bytes: buf.length } + } catch (e) { + return { ok: false, error: (e as Error).message } + } + }) + // --- Scope --- ipcMain.handle('scope:getViolations', () => scopeMonitor.getViolations()) ipcMain.handle('scope:getViolationCount', () => scopeMonitor.getViolationCount()) @@ -1324,11 +1463,26 @@ app.whenReady().then(() => { ipcMain.handle('cdp:setPort', (_e, port: number) => { setCdpPort(port); return true }) // --- Evidence bundle --- - ipcMain.handle('data:exportBundle', () => { + ipcMain.handle('data:exportBundle', (_e, opts?: { profile?: 'internal' | 'client-deliverable'; sanitizeUnknown?: boolean }) => { if (!activeProject) return { ok: false, error: 'no-active-project' } try { const cfg = loadConfig(getProjectPath(activeProject)) - const bundle = exportBundle(cfg.engagement.id) + // client-deliverable profile scope-sanitizes out-of-scope bodies (incl. io + // sidecars) before packaging (SPEC-SCOPE-AWARE-LIFECYCLE.md Part B). + const deliverable = opts?.profile === 'client-deliverable' + // The scope now travels with EVERY profile, not just the sanitizing one: + // the client-deliverable pass needs it to classify, and the adherence + // report (G-D1) needs it in both. An internal bundle that cannot state + // what it stayed inside of is missing the same proof. + const scope = currentScopeSnapshot(cfg) + const bundle = exportBundle(cfg.engagement.id, deliverable + ? { + profile: 'client-deliverable', + scope, + operatorId: currentOperatorId ?? '', + sanitizeUnknown: opts?.sanitizeUnknown === true + } + : { scope }) return { ok: true, outDir: bundle.outDir, manifest: bundle.manifest } } catch (e) { return { ok: false, error: (e as Error)?.message ?? String(e) } @@ -1381,9 +1535,36 @@ app.whenReady().then(() => { fs.writeFileSync(filePath, JSON.stringify(payload, null, 2)) return filePath } + // Both adherence entry points build the SAME report — a summary the operator + // reads and an export a client reads must not be able to disagree. The + // re-read here is deliberately read-only: `recordScopeProvenance` is not + // called, because an export must not manufacture history. + const currentAdherenceReport = (): ReturnType => + buildAdherenceReport( + queryEvents({ limit: 100000 }), + currentScopeSnapshot(loadConfig(getProjectPath(activeProject ?? ''))), + { generatedAt: Date.now(), engagementId: currentEngagementId } + ) ipcMain.handle('data:exportMarks', () => sliceExport('marks', listQuickMarks())) ipcMain.handle('data:exportLoot', () => sliceExport('loot', queryEvents({ agentType: 'loot', limit: 10000 }))) ipcMain.handle('data:exportViolations', () => sliceExport('scope-violations', queryEvents({ agentType: 'system', limit: 10000 }).filter((e) => e.data?.subtype === 'scope_violation'))) + // G-D1: the POSITIVE proof. exportViolations above is the accusation half — + // a client reading it cannot tell 3 near-misses out of 250 targets from 3 out + // of 5. RedLog does not prevent (ALERT-ROLES Part D), so "provably did not + // exceed scope" IS the deliverable, and it needs the denominator. + ipcMain.handle('data:exportAdherence', () => { + return sliceExport('scope-adherence', currentAdherenceReport()) + }) + ipcMain.handle('scope:adherenceSummary', () => { + if (!activeProject) return null + const report = currentAdherenceReport() + return { + totals: report.totals, + summary: summariseAdherence(report), + scopeChanges: report.scopeChanges.length, + provenance: report.scope.provenance ?? null + } + }) // v0.6.87 C2: Timeline slice export. Renderer picks a time window (usually // the current visible viewport in Timeline) and gets a filtered JSON slice // that a bug-bounty writeup can attach as evidence for a specific attack diff --git a/src/main/services/network-info.ts b/src/main/services/network-info.ts index e9602216..f19ac5d7 100644 --- a/src/main/services/network-info.ts +++ b/src/main/services/network-info.ts @@ -77,6 +77,21 @@ async function detectLinux(): Promise { return { type: 'unknown', name: '' } } +/** Apply `network.showWifiName`. The SSID is a **location disclosure**: it names + * the building the operator is sitting in, and it rides along on every + * `ip:status` into the HUD — which is the one surface guaranteed to be in + * frame on a screenshot or a screen-share. The setting existed and was + * honoured by nothing (G-NET1): the probe always ran and the name was always + * shown once the OS permission existed, whichever way the toggle was set. + * + * Off keeps the link TYPE (the UI renders a generic "Wi-Fi") and drops only + * the name — "am I on Wi-Fi or wired" is an OPSEC fact worth showing; "which + * Wi-Fi" is the part that leaks. */ +export function applyWifiNamePolicy(link: NetworkLink, showWifiName: boolean): NetworkLink { + if (showWifiName || link.type !== 'wifi' || !link.name) return link + return { type: 'wifi', name: '' } +} + export async function detectLink(): Promise { try { if (process.platform === 'darwin') return await detectMac() diff --git a/src/preload/index.ts b/src/preload/index.ts index ea83564d..75434057 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -79,7 +79,13 @@ contextBridge.exposeInMainWorld('redlog', { scope: { getViolations: () => ipcRenderer.invoke('scope:getViolations'), getViolationCount: () => ipcRenderer.invoke('scope:getViolationCount'), - isConfigured: () => ipcRenderer.invoke('scope:isConfigured') + isConfigured: () => ipcRenderer.invoke('scope:isConfigured'), + adherenceSummary: () => ipcRenderer.invoke('scope:adherenceSummary') + }, + // io_ref sidecar: pull a full HTTP body on demand by its digest (ref). + io: { + read: (ref: string, off?: number, len?: number): Promise<{ ok: boolean; text?: string; bytes?: number; error?: string; maxBytes?: number }> => + ipcRenderer.invoke('io:read', ref, off, len) }, chain: { length: () => ipcRenderer.invoke('chain:length'), @@ -115,11 +121,12 @@ contextBridge.exposeInMainWorld('redlog', { }, data: { exportJson: () => ipcRenderer.invoke('data:exportJson'), - exportBundle: () => ipcRenderer.invoke('data:exportBundle'), + exportBundle: (opts?: { profile?: 'internal' | 'client-deliverable'; sanitizeUnknown?: boolean }) => ipcRenderer.invoke('data:exportBundle', opts), exportScopeFiltered: () => ipcRenderer.invoke('data:exportScopeFiltered'), exportMarks: () => ipcRenderer.invoke('data:exportMarks'), exportLoot: () => ipcRenderer.invoke('data:exportLoot'), exportViolations: () => ipcRenderer.invoke('data:exportViolations'), + exportAdherence: () => ipcRenderer.invoke('data:exportAdherence'), exportTimelineSlice: (from: number, to: number) => ipcRenderer.invoke('data:exportTimelineSlice', { from, to }), revealPath: (target: string) => ipcRenderer.invoke('data:revealPath', target) }, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1320415c..afe46db3 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -7,7 +7,6 @@ import TimelinePanel from './components/Timeline' import EventMarker from './components/EventMarker' import Settings from './components/Settings' import ProjectPicker from './components/ProjectPicker' -import { TargetView } from './components/TargetView' import { ScopeStatus } from './components/ScopeStatus' import { LootPanel } from './components/LootPanel' import { SearchPanel } from './components/SearchPanel' @@ -15,12 +14,15 @@ import { ErrorBoundary } from './components/ErrorBoundary' import { QuickMarksView } from './components/FindingsView' import TerminalView from './components/TerminalView' import { ToastContainer } from './components/Toast' -import { LoadingSpinner } from './components/Feedback' +import { LoadingSpinner, EmptyState } from './components/Feedback' import { ConfirmDialogContainer, confirm as confirmDialog } from './components/ConfirmDialog' import { toast } from './components/Toast' import { computeCaptureReadiness } from './lib/captureReadiness' +import { emptyStateFor } from './lib/emptyState' +import { shortcutsForScope, MOD_TOKEN } from './lib/shortcuts' import { useI18n } from './i18n' import { loadSidebarOrder, onSidebarOrderChanged, type SidebarViewId } from './lib/sidebarOrder' +import { ICON } from './lib/icons' import logoUrl from './assets/logo.svg' type View = SidebarViewId | 'settings' | 'search' @@ -64,6 +66,31 @@ export default function App(): JSX.Element { const [markerAtTs, setMarkerAtTs] = useState(undefined) const { t } = useI18n() + // F4: turns an empty view's CTA target (from emptyStateFor) into an app-level + // action. Threaded to the empty views so their "nothing here" screens have a + // way forward instead of being dead ends. 'doc' has no in-app destination yet, + // so those CTAs are suppressed at the view rather than wired to a no-op. + const handleEmptyAction = (target: string): void => { + if (target === 'dashboard') setView('dashboard') + else if (target === 'marker') setShowMarker(true) + else if (target === 'screenshot') window.redlog.screenshot.capture().catch(() => {}) + } + + // Navigation. Phase C step 5 (O3): the standalone TargetView is gone — the + // timeline's target lane axis subsumes it — so the "Targets" entry deep-links + // into the Timeline with the target axis switched on (persisted + a live event + // in case the panel is already mounted). + const goTo = (v: View): void => { + setFocusEvent(null) + if (v === 'targets') { + try { localStorage.setItem('redlog-timeline-lane-axis', 'target') } catch { /* private mode */ } + window.dispatchEvent(new CustomEvent('redlog-timeline-set-axis', { detail: 'target' })) + setView('timeline') + return + } + setView(v) + } + useEffect(() => { window.redlog.project.active().then((p) => { if (p) setProject(p) @@ -123,6 +150,15 @@ export default function App(): JSX.Element { }).catch(() => {}) return } + // ⌘/Ctrl+B toggles the sidebar collapse (VS Code convention). The Sidebar + // owns the state + persistence; we just fire the event it listens for. + if (cmd && !e.shiftKey && !e.altKey && (e.key === 'b' || e.key === 'B')) { + const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase() + if (tag === 'input' || tag === 'textarea' || (e.target as HTMLElement | null)?.isContentEditable) return + e.preventDefault() + window.dispatchEvent(new CustomEvent('redlog:toggle-sidebar')) + return + } // ⌘⇧⌥ + arrow snaps the HUD to a corner of the display it's currently // on (audit finding #53). Was ⌘⌥ initially but macOS Sequoia's built-in // window tiling grabs that combo before the app sees it, so add Shift @@ -151,7 +187,7 @@ export default function App(): JSX.Element { const target = viewForShortcut(num) if (target) { e.preventDefault() - setView(target) + goTo(target) } } } @@ -191,6 +227,22 @@ export default function App(): JSX.Element { {project.name}
+ {/* F6: a persistent pointer entry to the Search view. It was only + reachable via ⌘/ (or ⌘K) before, so a newcomer couldn't find the + headline full-text search by looking. Kept out of the sidebar to + leave DEFAULT_ORDER length + the ⌘1..N numbering untouched. */} +
@@ -831,8 +885,14 @@ function DashboardView({ onNavigate }: { onNavigate: (v: string) => void }): JSX // Settings is pinned to ⌘9 rather than carried by shortcutOrder, // so it has to be listed explicitly (v0.11.2). [`${modKey}9`, t('sidebar.settings')] as [string, string], - [isMac ? '⌘⇧M' : 'Ctrl+Shift+M', t('dashboard.addMarker')] as [string, string], - [`${modKey}/`, t('dashboard.search')] as [string, string] + // Non-view global shortcuts come from the single shortcut registry + // (lib/shortcuts.ts) so this card and the Timeline `?` cheatsheet + // stay in sync — adding a global shortcut there surfaces it here, + // and it picks up ones this card used to omit (e.g. ⌘. pause). + // switch-view is rendered dynamically above (per reorderable order). + ...shortcutsForScope('global') + .filter((s) => s.id !== 'switch-view') + .map((s) => [s.keys.replaceAll(MOD_TOKEN, isMac ? '⌘' : 'Ctrl+'), t(s.labelKey)] as [string, string]) ].map(([key, label]) => (
{key} @@ -865,7 +925,7 @@ function StatCard({ label, value, sub, tone = 'neutral' }: { ) } -function ScreenshotsView(): JSX.Element { +function ScreenshotsView({ onEmptyAction }: { onEmptyAction?: (target: string) => void }): JSX.Element { const [screenshots, setScreenshots] = useState([]) const [thumbs, setThumbs] = useState>({}) const [expanded, setExpanded] = useState(null) @@ -937,15 +997,21 @@ function ScreenshotsView(): JSX.Element { {t('screenshots.captureNow')}
- {screenshots.length === 0 ? ( -
-
- ◻ -
-

{t('screenshots.empty')}

-

{t('screenshots.emptyDesc')}

-
- ) : (() => { + {screenshots.length === 0 ? (() => { + // F4: the shared EmptyState with a working CTA, driven by the pure + // emptyStateFor map, instead of a hand-rolled dead end. + const es = emptyStateFor('screenshots', { captureDark: false }) + return ( + onEmptyAction?.(es.action!.target) } + : undefined} + /> + ) + })() : (() => { // Trigger filter (audit #32) — all captures land in one grid mixing // periodic / manual / mark-triggered. Chip toggles narrow the view. const triggerCounts = new Map() @@ -1023,7 +1089,7 @@ function ScreenshotsView(): JSX.Element { } }} onKeyDown={(e) => e.stopPropagation()} - className="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100 text-xs text-zinc-600 hover:text-red-400 focus-visible:text-red-400 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-red-500/40 rounded transition-opacity" + className="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100 text-xs text-zinc-600 hover:text-red-400 focus-visible:text-red-400 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-red-500/40 rounded transition-opacity hit-target" title={t('screenshots.deleteTitle')} aria-label={t('screenshots.deleteTitle')} >× diff --git a/src/renderer/src/OverlayApp.tsx b/src/renderer/src/OverlayApp.tsx index 500fae41..d1136a2b 100644 --- a/src/renderer/src/OverlayApp.tsx +++ b/src/renderer/src/OverlayApp.tsx @@ -1,4 +1,6 @@ import { useEffect, useState, useRef, useLayoutEffect } from 'react' +import { ipBadge } from './lib/ip-badge' +import { SEVERITY_HUD } from './lib/alertSeverity' import { useI18n } from './i18n' import { HUD, hexA } from './lib/hud' import { usePivots } from './lib/usePivots' @@ -104,11 +106,11 @@ export default function OverlayApp(): JSX.Element { return () => { if (timerRef.current) clearTimeout(timerRef.current) } }, [expanded, interactive, pinned]) - const collapse = (): void => { setExpanded(false); window.redlog.overlay?.setExpanded(false) } + const collapse = (): void => { setExpanded(false); window.redlog.overlay?.setExpanded?.(false) } const toggleExpand = (): void => { const next = !expanded setExpanded(next) - window.redlog.overlay?.setExpanded(next) + window.redlog.overlay?.setExpanded?.(next) } // fs = font-size scaler; ip = extra emphasis for the external IP. @@ -131,19 +133,27 @@ export default function OverlayApp(): JSX.Element { // same factor. Without this the middle gap stays fixed while text shrinks — // small scale looks loose, large scale looks cramped. const px = (n: number): number => Math.round(n * s) - const hair = + const hair = + // G-A3: `badge.tone` is the verdict AFTER stale decay, so a green frame can + // never outlive the reading behind it. `qualified` dims the tick. + const badge = ipBadge(status) const safety = status?.ipSafety ?? 'unknown' const link = status?.link const linkText = link?.type === 'wifi' ? (link.name || 'Wi-Fi') : link?.type === 'wired' ? t('overlay.wired') : '' - const STATE = { safe: HUD.green, exposed: HUD.red, unknown: HUD.amber }[safety] - const LABEL = { safe: t('overlay.safeIp'), exposed: t('overlay.exposedIp'), unknown: t('overlay.ipUnknown') }[safety] - const STATUS_TXT = { safe: t('overlay.safeIpStatus'), exposed: t('overlay.exposedIpStatus'), unknown: t('overlay.unknownIp') }[safety] + // G-C1: the frame colour is the shared scale; only the words are per-verdict. + const STATE = SEVERITY_HUD[badge.severity] + const LABEL = badge.reason === 'stale' + ? t('overlay.ipStale') + : { safe: t('overlay.safeIp'), presumed_safe: t('overlay.presumedSafeIp'), off_profile: t('overlay.offProfileIp'), exposed: t('overlay.exposedIp'), unknown: t('overlay.ipUnknown') }[safety] + const STATUS_TXT = badge.reason === 'stale' + ? t('overlay.staleIpStatus') + : { safe: t('overlay.safeIpStatus'), presumed_safe: t('overlay.presumedSafeIpStatus'), off_profile: t('overlay.offProfileIpStatus'), exposed: t('overlay.exposedIpStatus'), unknown: t('overlay.unknownIp') }[safety] // exposure turns the whole frame into a red-alert scan; otherwise cyan HUD. - const FRAME = safety === 'exposed' ? HUD.red : CYAN + const FRAME = badge.severity === 'critical' ? HUD.red : CYAN // when EXPOSED, flash the whole frame as an unmissable alarm (opt-out in Settings). - const alarm = safety === 'exposed' && flashExposed + const alarm = badge.severity === 'critical' && !badge.qualified && flashExposed const noDrag = { WebkitAppRegion: 'no-drag' } as React.CSSProperties const BTN_CLIP = 'polygon(6px 0,100% 0,100% calc(100% - 6px),calc(100% - 6px) 100%,0 100%,0 6px)' @@ -153,12 +163,14 @@ export default function OverlayApp(): JSX.Element { const iconBtn: React.CSSProperties = { color: CYAN, fontSize: fs(10), cursor: 'pointer', width: 18, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center', - background: interactive ? 'rgba(34,211,238,0.10)' : 'transparent', - border: `1px solid ${interactive ? 'rgba(34,211,238,0.35)' : 'transparent'}`, + background: interactive ? hexA(CYAN, 0.10) : 'transparent', + border: `1px solid ${interactive ? hexA(CYAN, 0.35) : 'transparent'}`, transition: 'background 0.12s, border-color 0.12s' } const latestPivot = pivots[0] - const tick = (c: string): React.CSSProperties => ({ width: 6, height: 6, borderRadius: '50%', background: c, boxShadow: `0 0 7px ${c}, 0 0 3px ${c}`, flexShrink: 0 }) + const tick = (c: string): React.CSSProperties => badge.qualified + ? { width: 6, height: 6, borderRadius: '50%', background: 'transparent', border: `1px solid ${c}`, boxShadow: 'none', flexShrink: 0 } + : { width: 6, height: 6, borderRadius: '50%', background: c, boxShadow: `0 0 7px ${c}, 0 0 3px ${c}`, flexShrink: 0 } // Pass-through dimming — applied to individual "chrome" children instead // of the outer wrapper. CSS opacity on a parent forces every descendant @@ -184,7 +196,7 @@ export default function OverlayApp(): JSX.Element { }} > {/* scanlines */} -
+
{/* buttons (top-right): expand + hide only. The pin toggle used to live here too, but the operator asked to move it into the @@ -326,7 +338,7 @@ export default function OverlayApp(): JSX.Element { {justMarked ? `✓ ${t('overlay.marked').toUpperCase()}` : `⚡ ${t('overlay.markQuick').toUpperCase()}`} - )} -
+
- {/* Filter + dedup chips (only when there's enough loot to matter) */} - {lootEvents.length > 0 && (() => { - const typeCounts = new Map() - for (const le of lootEvents) for (const m of le.matches) typeCounts.set(m.type, (typeCounts.get(m.type) ?? 0) + 1) - const types = [...typeCounts.entries()].sort((a, b) => b[1] - a[1]) - if (types.length < 2 && lootEvents.length < 5) return null - return ( -
- {types.length > 1 && types.map(([type, count]) => ( - - ))} - - - -
- ) - })()} + {/* Filter + dedup chips (only when there's enough loot to matter) */} + {(() => { + const typeCounts = new Map() + for (const le of lootEvents) for (const m of le.matches) typeCounts.set(m.type, (typeCounts.get(m.type) ?? 0) + 1) + const types = [...typeCounts.entries()].sort((a, b) => b[1] - a[1]) + if (types.length < 2 && lootEvents.length < 5) return null + return ( +
+ {types.length > 1 && types.map(([type, count]) => ( + + ))} + + + +
+ ) + })()} - {lootEvents.length === 0 ? ( -
-
- ◆ -
-

{t('loot.empty')}

-

{t('loot.emptyDesc')}

-
- ) : ( - // v0.7.1 P1: rendering uses the same `visibleList` that feeds the - // header count, so what you see and what the header says can never - // disagree. Each source event keeps its own grouping so the "click - // card → jump to timeline" flow still lands on a real event id.
{visibleList.length === 0 && (

{t('loot.noMatches')}

)} - {visibleList.map((le, i) => ( -
onOpenInTimeline?.(le.id, le.timestamp)} - onKeyDown={onOpenInTimeline ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpenInTimeline(le.id, le.timestamp) } } : undefined} - className={`bg-zinc-900 border border-zinc-800 rounded-lg p-3 ${onOpenInTimeline ? 'cursor-pointer hover:border-cyan-500/40 hover:bg-zinc-900/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/40 transition-colors' : ''}`} - title={onOpenInTimeline ? t('loot.openInTimelineHint') : undefined} - > -
-
+ {visibleList.map((le, i) => { + const isSel = selectedId === le.id + return ( +
setSelectedId(le.id)} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedId(le.id) } }} + className={`bg-zinc-900 border rounded-lg p-3 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/40 transition-colors ${ + isSel ? 'border-cyan-500/60 bg-zinc-900/60' : 'border-zinc-800 hover:border-cyan-500/40 hover:bg-zinc-900/60' + }`} + title={t('loot.selectHint')} + > +
{new Date(le.timestamp).toLocaleString()} {le.source && ( · {t('loot.from')} {le.source} )} - {le.targetId && ( - · {t('loot.target')} {le.targetId} - )} · {t('loot.items', { count: le.matches.length })}
- {onOpenInTimeline && ( - - {t('loot.openInTimeline')} → - - )} -
- {le.matches.map((m, j) => ( -
-
- - {m.type.replace(/_/g, ' ')} - - - {m.confidence} - + {le.matches.map((m, j) => ( +
+
+ + {m.type.replace(/_/g, ' ')} + + + {m.confidence} + +
+
{m.preview}
-
{m.preview}
-
- ))} -
- ))} + ))} +
+ ) + })} +
+
+ + + + ) +} + +// The detail pane: the full, un-truncated, copyable value for the selected loot +// event (audit C1 — the list preview is `truncate` and had no copy affordance). +function LootDetail({ le, typeColor, onOpenInTimeline }: { + le: LootEvent | null + typeColor: Record + onOpenInTimeline?: (eventId: string, ts: number) => void +}): JSX.Element { + const { t } = useI18n() + if (!le) { + return ( +
+ {ICON.loot} + {t('loot.selectPrompt')} +
+ ) + } + const copy = (value: string): void => { + navigator.clipboard.writeText(value) + .then(() => toast(t('loot.copied'), 'success')) + .catch(() => toast(t('loot.copyFailed'), 'error')) + } + return ( +
+
+
+
{new Date(le.timestamp).toLocaleString()}
+ {le.source &&
{t('loot.from')} {le.source}
} + {le.targetId &&
{t('loot.target')} {le.targetId}
} +
+ {onOpenInTimeline && ( + + )} +
+ {le.matches.map((m, j) => ( +
+
+ {m.type.replace(/_/g, ' ')} + {m.confidence} + +
+ {/* Full value — selectable + wrapped, no truncation. */} +
{m.preview}
- )} + ))}
) } diff --git a/src/renderer/src/components/ProjectPicker.tsx b/src/renderer/src/components/ProjectPicker.tsx index d6152c44..66d7e3d1 100644 --- a/src/renderer/src/components/ProjectPicker.tsx +++ b/src/renderer/src/components/ProjectPicker.tsx @@ -48,7 +48,14 @@ export default function ProjectPicker({ onProjectOpen }: ProjectPickerProps): JS try { const initialConfig = (showAdvanced && (scopeTargets.length > 0 || whitelist.length > 0 || blacklist.length > 0)) ? { - scope: { targets: scopeTargets, excludeTargets: [], warnOnViolation, scopeFile: null }, + // The two-value shortcut for the alert floor (G-C3). Project setup + // is a quick start; the full three-way control lives in Settings. + scope: { + targets: scopeTargets, + excludeTargets: [], + alertFloor: (warnOnViolation ? 'adjacent' : 'excluded_only') as 'adjacent' | 'excluded_only', + scopeFile: null + }, network: { whitelist, blacklist, checkInterval: 60 } } : undefined @@ -81,10 +88,11 @@ export default function ProjectPicker({ onProjectOpen }: ProjectPickerProps): JS const profile = await window.redlog.config.importProfile() as RedLogConfigPartial | null if (!profile) return if (profile.scope?.targets) setScopeTargets(profile.scope.targets) - if (profile.network?.whitelist ?? profile.network?.safeIPs) setWhitelist(profile.network.whitelist ?? profile.network.safeIPs) - if (profile.network?.blacklist ?? profile.network?.exposedIPs) setBlacklist(profile.network.blacklist ?? profile.network.exposedIPs) + if (profile.network?.whitelist ?? profile.network?.safeIPs) setWhitelist((profile.network.whitelist ?? profile.network.safeIPs)!) + if (profile.network?.blacklist ?? profile.network?.exposedIPs) setBlacklist((profile.network.blacklist ?? profile.network.exposedIPs)!) // Migrate legacy 'log' → warnings off; 'warn' or unset → on. Direct boolean wins. - if (profile.scope?.warnOnViolation !== undefined) setWarnOnViolation(profile.scope.warnOnViolation) + if (profile.scope?.alertFloor !== undefined) setWarnOnViolation(profile.scope.alertFloor !== 'excluded_only') + else if (profile.scope?.warnOnViolation !== undefined) setWarnOnViolation(profile.scope.warnOnViolation) else if (profile.scope?.enforcement) setWarnOnViolation(profile.scope.enforcement !== 'log') setShowAdvanced(true) toast(t('toast.profileImported'), 'success') diff --git a/src/renderer/src/components/ScopeStatus.tsx b/src/renderer/src/components/ScopeStatus.tsx index 5cc93c2f..18c8b4ca 100644 --- a/src/renderer/src/components/ScopeStatus.tsx +++ b/src/renderer/src/components/ScopeStatus.tsx @@ -1,11 +1,22 @@ import { useState, useEffect } from 'react' +import { SEVERITY_CLASS, scopeSeverity, worstSeverity } from '../lib/alertSeverity' import { useI18n } from '../i18n' import { LoadingSpinner } from './Feedback' +import { ICON } from '../lib/icons' import { toast } from './Toast' export function ScopeStatus({ onOpenInTimeline }: { onOpenInTimeline?: (ts: number) => void } = {}): JSX.Element { - const [violations, setViolations] = useState>([]) + const [violations, setViolations] = useState>([]) const [configured, setConfigured] = useState(false) + // G-D1: the denominator. "3 violations" alone cannot tell a client 3-out-of-250 + // from 3-out-of-5, and RedLog's deliverable is "provably stayed in scope". + const [adherence, setAdherence] = useState>>>(null) const [chainLen, setChainLen] = useState(0) const [loading, setLoading] = useState(true) const { t } = useI18n() @@ -14,12 +25,14 @@ export function ScopeStatus({ onOpenInTimeline }: { onOpenInTimeline?: (ts: numb Promise.all([ window.redlog.scope.isConfigured().then(setConfigured), window.redlog.scope.getViolations().then(setViolations), + window.redlog.scope.adherenceSummary?.().then(setAdherence).catch(() => {}), window.redlog.chain.length().then(setChainLen) ]).then(() => setLoading(false)) const unsub = window.redlog.events.onNew((event) => { if (event.agentType === 'system' && (event.data as Record)?.subtype === 'scope_violation') { window.redlog.scope.getViolations().then(setViolations) + window.redlog.scope.adherenceSummary?.().then(setAdherence).catch(() => {}) } window.redlog.chain.length().then(setChainLen) }) @@ -47,6 +60,15 @@ export function ScopeStatus({ onOpenInTimeline }: { onOpenInTimeline?: (ts: numb title={t('scope.exportHint')} >{t('scope.export')} )} +
@@ -62,14 +84,47 @@ export function ScopeStatus({ onOpenInTimeline }: { onOpenInTimeline?: (ts: numb

{t('scope.allInScope')}

)} {violations.length > 0 && ( -
+
scopeSeverity(v.reason)))].text} text-sm`}> {t('scope.violations', { count: violations.length })}
)} + {configured && adherence && adherence.totals.targets > 0 && ( +
+

{adherence.summary}

+

{t('scope.adherenceHint', { actions: adherence.totals.actions })}

+ {/* Re-classification runs against the CURRENT scope. If the scope was + edited mid-engagement the counts describe the work as judged by + today's list — a caveat, said out loud rather than swallowed. */} + {adherence.scopeChanges > 0 && ( +

{t('scope.adherenceScopeChanged', { n: adherence.scopeChanges })}

+ )} + {/* G-D2: which document this scope came from. The digest is what + lets a reviewer join the report to the authorisation file they + were handed, instead of taking the path on trust. */} + {adherence.provenance && !adherence.provenance.error && ( +

+ {t('scope.provenance', { + entries: adherence.provenance.entries, + digest: adherence.provenance.digest.slice(0, 12) + })} +

+ )} + {/* A scope file that parses to nothing contributes no targets and + said nothing about it — "scope active" with an empty document + reads exactly like a correctly loaded one. */} + {adherence.provenance && adherence.provenance.entries === 0 && ( +

+ {adherence.provenance.error + ? t('scope.provenanceUnreadable', { path: adherence.provenance.path }) + : t('scope.provenanceEmpty', { path: adherence.provenance.path })} +

+ )} +
+ )} {!configured && (
- ⊘ + {ICON.scope}

{t('scope.hint')} @@ -88,19 +143,35 @@ export function ScopeStatus({ onOpenInTimeline }: { onOpenInTimeline?: (ts: numb {violations.length > 0 && (

{t('scope.recentViolations')}

- {violations.slice(0, 10).map((v, i) => ( - - ))} + {/* G-C1: every violation used to render in the same red, so "you touched + a host the client explicitly forbade" and "you touched a neighbour + of one you were allowed" were visually identical. Colour is the + shared severity scale; a dashed border marks the §3 inferences. */} + {violations.slice(0, 10).map((v, i) => { + const sev = scopeSeverity(v.reason) + const inferred = v.authority === 'inferred' + const tint = sev === 'critical' + ? 'bg-red-900/20 border-red-800/30 hover:bg-red-900/30 disabled:hover:bg-red-900/20' + : sev === 'warn' + ? 'bg-orange-900/15 border-orange-800/30 hover:bg-orange-900/25 disabled:hover:bg-orange-900/15' + : 'bg-zinc-800/30 border-zinc-700/40 hover:bg-zinc-800/50 disabled:hover:bg-zinc-800/30' + return ( + + ) + })}
)}
diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index 4a777389..106002c6 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -3,6 +3,7 @@ import { useI18n, type Locale } from '../i18n' import { toast } from './Toast' import { confirm as confirmDialog } from './ConfirmDialog' import { setLastVerifyResult, type FullVerifyResult as CachedFullVerifyResult } from '../lib/verifyResultCache' +import { matchGroups, type SettingsGroupIndex } from '../lib/settingsSearch' // The Wi-Fi-name toggle only means anything on macOS (where the SSID is gated // behind Location Services). Windows/Linux read the SSID directly, so the @@ -12,10 +13,10 @@ const isMacOS = (window as { redlog?: { platform?: string } }).redlog?.platform interface ConfigState { engagement: { id: string; name: string } operator: { id: string; name: string } - network: { whitelist: string[]; blacklist: string[]; checkInterval: number; providers?: string[]; confirmations?: number; ipMode?: 'dns' | 'http' | 'auto'; showWifiName?: boolean; vpnAdapters?: Array<{ name: string; pattern: string; enabled: boolean }> } - scope: { warnOnViolation?: boolean; targets: string[]; excludeTargets: string[]; scopeFile: string } - screenshot: { quality: number } - overlay?: { showMarkButton: boolean; showInDock?: boolean; flashOnExposed?: boolean; scale?: number; emphasizeExternalIp?: boolean; passThrough?: boolean; passThroughOpacity?: number } + network: { whitelist: string[]; blacklist: string[]; lanProfile?: string[]; checkInterval: number; providers?: string[]; confirmations?: number; ipMode?: 'dns' | 'http' | 'auto'; showWifiName?: boolean; vpnAdapters?: Array<{ name: string; pattern: string; enabled: boolean }> } + scope: { alertFloor?: 'excluded_only' | 'adjacent' | 'all'; targets: string[]; excludeTargets: string[]; proximityBits?: number; scopeFile: string } + screenshot: { quality: number; intervalSec?: number } + overlay?: { showMarkButton?: boolean; showInDock?: boolean; flashOnExposed?: boolean; scale?: number; emphasizeExternalIp?: boolean; passThrough?: boolean; passThroughOpacity?: number } clipboard?: { enabled: boolean; pollMs?: number; storePreview?: boolean } fileWatcher?: { enabled: boolean; watchPaths?: string[]; ignorePatterns?: string[] } processMonitor?: { enabled: boolean; pollMs?: number; ignoreCommands?: string[] } @@ -35,6 +36,7 @@ interface ConfigState { events: string[] subtypes: string[] includeData: boolean + authorityFloor?: 'inferred' | 'fact' } // v0.7.7 U1: Settings ▸ AI Agents surface for the built-in Claude Code // tailer. v0.8.0 will expand this into a list of installed tailer @@ -68,9 +70,70 @@ const LOCALE_LABELS: Record = { 'zh-TW': '繁體中文' } +// v0.11.8 (DESIGN-SETTINGS-IA Option B): five tabs, ordered by the operator's +// question. Advanced is a two-level tab whose sections are collapsed by default. +type TabId = 'capture' | 'scope' | 'evidence' | 'opsec' | 'advanced' + +// F3 (UX-BACKLOG / DESIGN-PRINCIPLES §9): a static index of every FieldGroup on +// the page so the header filter can find a setting by its group title OR any of +// its field labels without the operator having to remember which tab it lives +// in. Titles and labels are i18n keys resolved at render time; the pure match +// rule lives in lib/settingsSearch.ts. Order mirrors on-screen order so a +// filtered result list reads top-to-bottom like the real page. This is +// presentation metadata only — it points at controls, it does not own them. +interface SettingsGroupSource { + tab: TabId + groupId: string + titleKey: string + labelKeys: string[] +} + +const SETTINGS_GROUPS: SettingsGroupSource[] = [ + // capture + { tab: 'capture', groupId: 'hooks', titleKey: 'settings.hooksDetected', labelKeys: [] }, + { tab: 'capture', groupId: 'agents', titleKey: 'settings.agents', labelKeys: [] }, + { tab: 'capture', groupId: 'clipboard', titleKey: 'settings.clipboardGroup', labelKeys: ['settings.clipboardEnable', 'settings.clipboardStorePreview'] }, + { tab: 'capture', groupId: 'screenshotQuality', titleKey: 'settings.screenshotQuality', labelKeys: ['settings.jpegQuality'] }, + { tab: 'capture', groupId: 'screenshotSchedule', titleKey: 'settings.screenshotGroup', labelKeys: [] }, + { tab: 'capture', groupId: 'fileWatcher', titleKey: 'settings.fileWatcherGroup', labelKeys: ['settings.fileWatcherPaths', 'settings.fileWatcherIgnore'] }, + { tab: 'capture', groupId: 'processMonitor', titleKey: 'settings.processMonitorGroup', labelKeys: ['settings.processMonitorIgnore'] }, + { tab: 'capture', groupId: 'hookExcludedPaths', titleKey: 'settings.hookExcludedPaths.title', labelKeys: [] }, + // scope + { tab: 'scope', groupId: 'scopeEnforcement', titleKey: 'settings.scopeEnforcement', labelKeys: ['settings.alertFloor.adjacent', 'settings.proximityBits'] }, + { tab: 'scope', groupId: 'inScopeTargets', titleKey: 'settings.inScopeTargets', labelKeys: ['settings.targetsLabel'] }, + { tab: 'scope', groupId: 'excludedTargets', titleKey: 'settings.excludedTargets', labelKeys: ['settings.excludeLabel'] }, + { tab: 'scope', groupId: 'scopeFile', titleKey: 'settings.scopeFile', labelKeys: ['settings.scopeFileLabel'] }, + // evidence + { tab: 'evidence', groupId: 'engagement', titleKey: 'settings.engagement', labelKeys: ['settings.id', 'settings.name'] }, + { tab: 'evidence', groupId: 'operator', titleKey: 'settings.operatorGroup', labelKeys: ['settings.id', 'settings.name'] }, + { tab: 'evidence', groupId: 'operators', titleKey: 'settings.operators', labelKeys: [] }, + { tab: 'evidence', groupId: 'integrity', titleKey: 'settings.integrity', labelKeys: [] }, + { tab: 'evidence', groupId: 'exportAll', titleKey: 'settings.exportAll', labelKeys: ['settings.exportJson'] }, + { tab: 'evidence', groupId: 'exportBundle', titleKey: 'settings.exportBundleTitle', labelKeys: [] }, + { tab: 'evidence', groupId: 'scopeExport', titleKey: 'settings.scopeExport', labelKeys: ['settings.exportScopeJson'] }, + { tab: 'evidence', groupId: 'profileSync', titleKey: 'settings.profileSync', labelKeys: ['settings.exportProfile', 'settings.importProfile'] }, + // opsec + { tab: 'opsec', groupId: 'overlay', titleKey: 'settings.overlayGroup', labelKeys: ['settings.overlayShowInDock'] }, + { tab: 'opsec', groupId: 'ipSafety', titleKey: 'settings.ipSafety', labelKeys: ['settings.whitelist', 'settings.blacklist', 'settings.lanProfile'] }, + { tab: 'opsec', groupId: 'polling', titleKey: 'settings.polling', labelKeys: ['settings.ipMode', 'settings.checkInterval', 'settings.confirmations', 'settings.ipProviders', 'settings.showWifiName'] }, + { tab: 'opsec', groupId: 'vpnAdapters', titleKey: 'settings.vpnAdapters', labelKeys: [] }, + // advanced + { tab: 'advanced', groupId: 'mcp', titleKey: 'settings.mcp', labelKeys: [] }, + { tab: 'advanced', groupId: 'browser', titleKey: 'settings.browser', labelKeys: [] }, + { tab: 'advanced', groupId: 'cdp', titleKey: 'settings.cdp', labelKeys: ['settings.testConnection'] }, + { tab: 'advanced', groupId: 'deconfliction', titleKey: 'settings.deconfliction', labelKeys: [] }, + { tab: 'advanced', groupId: 'plugins', titleKey: 'settings.plugins', labelKeys: [] }, + { tab: 'advanced', groupId: 'marketplace', titleKey: 'settings.marketplace', labelKeys: [] }, + { tab: 'advanced', groupId: 'cloudShare', titleKey: 'cloudShare.title', labelKeys: [] }, + { tab: 'advanced', groupId: 'language', titleKey: 'settings.language', labelKeys: [] }, + { tab: 'advanced', groupId: 'uiScale', titleKey: 'settings.uiScale', labelKeys: [] }, + { tab: 'advanced', groupId: 'update', titleKey: 'settings.updateGroup', labelKeys: ['settings.checkUpdate'] } +] + export default function Settings(): JSX.Element { const [config, setConfig] = useState(null) - const [tab, setTab] = useState<'general' | 'hud' | 'capture' | 'network' | 'scope' | 'integrations' | 'data' | 'plugins'>('general') + const [tab, setTab] = useState('capture') + const [filter, setFilter] = useState('') const [saved, setSaved] = useState(false) const [exportResult, setExportResult] = useState(null) const [hooks, setHooks] = useState([]) @@ -103,30 +166,36 @@ export default function Settings(): JSX.Element { if (!config) return
{t('settings.loading')}
- // v0.9.10: ten tabs down to eight, ordered by the question the operator is - // actually asking rather than by when each feature was added. - // identity -> what gets recorded -> what's in bounds -> exposure -> - // display -> external tools -> retention -> extensions - // - // Two merges: - // · AI agent transcripts moved into Capture. It is a passive capture - // source exactly like the clipboard, the file watcher and the process - // monitor, all of which already live there; on its own it was a tab - // holding three checkboxes, which made it look like a subsystem rather - // than one source among several. - // · Marketplace folded into Plugins as a sub-tab. "What is installed" and - // "where to get more" are one task split across two top-level tabs. + // v0.11.8 (DESIGN-SETTINGS-IA Option B): eight tabs down to five, each named + // for the operator's question rather than a subsystem — + // what gets recorded (Capture) -> what's in bounds (Scope) -> + // what proves the work (Evidence) -> what leaks (OPSEC) -> the rest. + // General/HUD/Network/Integrations/Data dissolve: HUD+Network merge into one + // OPSEC front door; identity+integrity+export gather under Evidence; and + // everything rarely touched folds into Advanced, whose sections are collapsed + // by default (progressive disclosure). const tabs = [ - { id: 'general' as const, label: t('settings.general') }, { id: 'capture' as const, label: t('settings.capture') }, { id: 'scope' as const, label: t('settings.scope') }, - { id: 'network' as const, label: t('settings.networkIp') }, - { id: 'hud' as const, label: t('settings.hud') }, - { id: 'integrations' as const, label: t('settings.integrations') }, - { id: 'data' as const, label: t('settings.data') }, - { id: 'plugins' as const, label: t('settings.plugins') } + { id: 'evidence' as const, label: t('settings.evidence') }, + { id: 'opsec' as const, label: t('settings.opsec') }, + { id: 'advanced' as const, label: t('settings.advanced') } ] + // F3: resolve the static group index against the current locale and run the + // pure matcher. An empty/whitespace query means "no filter" — the page keeps + // its normal tabbed view; a real query flattens hits from every tab into one + // navigable list so a setting is found by name, not by remembering its tab. + const query = filter.trim() + const groupIndex: SettingsGroupIndex[] = SETTINGS_GROUPS.map((g) => ({ + tab: g.tab, + groupId: g.groupId, + title: t(g.titleKey), + labels: g.labelKeys.map((k) => t(k)) + })) + const matchedGroups = query ? matchGroups(filter, groupIndex) : [] + const tabLabel = (id: string): string => tabs.find((tb) => tb.id === id)?.label ?? id + return (
@@ -141,273 +210,44 @@ export default function Settings(): JSX.Element { {tb.label} ))} - {saved && {t('settings.saved')}} + setFilter(e.target.value)} + placeholder={t('settings.filterPlaceholder')} + aria-label={t('settings.filterPlaceholder')} + className="ml-auto w-44 px-2 py-1 text-xs bg-zinc-900 border border-redlog-border rounded text-zinc-200 placeholder-zinc-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/40" + /> + {saved && {t('settings.saved')}}
- {tab === 'general' && ( - <> - - setConfig({ ...config, engagement: { ...config.engagement, id: v } })} /> - setConfig({ ...config, engagement: { ...config.engagement, name: v } })} /> - - - setConfig({ ...config, operator: { ...config.operator, id: v } })} /> - setConfig({ ...config, operator: { ...config.operator, name: v } })} /> - - -
- {(Object.keys(LOCALE_LABELS) as Locale[]).map((l) => ( - - ))} -
-
- - - - - )} - - {tab === 'integrations' && ( - <> - - - - - - -

{t('settings.cdpHint')}

+ {query ? ( + matchedGroups.length === 0 ? ( +

{t('settings.filterNoResults', { query: filter })}

+ ) : ( + // Flattened cross-tab results: each hit is a button that jumps to the + // owning tab (and clears the filter so the real controls show). The + // tab name travels with every hit so the operator knows where the + // setting lives — the F3 acceptance case ("typing 'exclude' surfaces + // the scope exclusion list regardless of active tab"). + matchedGroups.map((g) => ( -
- - )} - - {tab === 'network' && ( - <> - - setConfig({ ...config, network: { ...config.network, whitelist: items } })} - placeholder={t('settings.safeIpPlaceholder')} - /> - setConfig({ ...config, network: { ...config.network, blacklist: items } })} - placeholder={t('settings.exposedIpPlaceholder')} - /> - - -
- -
- {(['auto', 'dns', 'http'] as const).map((m) => ( - - ))} -
-

{t('settings.ipModeHint')}

-
- {isMacOS && ( -
- -

{t('settings.showWifiNameHint')}

-
- )} - setConfig({ ...config, network: { ...config.network, checkInterval: parseInt(v) || 60 } })} - type="number" - /> -

{t('settings.pollingOpsecHint')}

- setConfig({ ...config, network: { ...config.network, confirmations: Math.max(1, parseInt(v) || 3) } })} - type="number" - /> -

{t('settings.confirmationsHint')}

- setConfig({ ...config, network: { ...config.network, providers: items } })} - placeholder="https://ip.internal.example/json" - /> -

{t('settings.ipProvidersHint')}

-
- - - )} - - {tab === 'hud' && ( - <> - - -

{t('settings.overlayShowMarkHint')}

- -

{t('settings.overlayFlashExposedHint')}

- -
- -
- {[ - { v: 0.85, k: 'small' }, - { v: 1.0, k: 'normal' }, - { v: 1.25, k: 'large' }, - { v: 1.5, k: 'xlarge' } - ].map(({ v, k }) => { - const cur = config.overlay?.scale ?? 1.0 - const active = Math.abs(cur - v) < 0.01 - return ( - - ) - })} -
-

{t('settings.overlayScaleHint')}

-
- - -

{t('settings.overlayEmphasizeIpHint')}

- - -

{t('settings.overlayPassThroughHint')}

- {config.overlay?.passThrough === true && ( -
- {t('settings.overlayPassThroughOpacity')} - setConfig({ ...config, overlay: { ...config.overlay, passThroughOpacity: parseFloat(e.target.value) } })} - className="accent-red-600 w-40" - /> - {Math.round((config.overlay?.passThroughOpacity ?? 0.4) * 100)}% -
- )} - - {isMacOS && ( - <> - -

{t('settings.overlayShowInDockHint')}

- - )} -
- - )} - + )) + ) + ) : ( + <> {tab === 'capture' && ( <> - - setConfig({ ...config, screenshot: { ...config.screenshot, quality: Math.min(100, Math.max(1, parseInt(v) || 85)) } })} - type="number" - /> -

- {t('settings.qualityHint')} -

-
+ + setConfig({ ...config, screenshot: { ...config.screenshot, quality: Math.min(100, Math.max(1, parseInt(v) || 85)) } })} + type="number" + /> +

+ {t('settings.qualityHint')} +

+
+ +
+ {[ + { v: 0, k: 'settings.screenshot.interval.off' }, + { v: 30, k: 'settings.screenshot.interval.30s' }, + { v: 60, k: 'settings.screenshot.interval.60s' }, + { v: 300, k: 'settings.screenshot.interval.5m' } + ].map((opt) => ( + + ))} +
+

{t('settings.screenshot.intervalHint')}

+
+ + + )} + + {tab === 'scope' && ( + <> + + {/* G-C3: the distance ladder is ORDERED, so the control is a floor + rather than N booleans — those would let an operator build + incoherent states ("warn on unrelated but not on adjacent"). + D1 is present in every option by construction, which is the + fact-tier rule made structural instead of remembered. */} +
+ {(['excluded_only', 'adjacent', 'all'] as const).map((floor) => { + const active = (config.scope.alertFloor ?? 'adjacent') === floor + return ( + + ) + })} +
+

+ {t(`settings.alertFloorHint.${config.scope.alertFloor ?? 'adjacent'}`)} +

+

{t('settings.alertFloorD1Note')}

+ {/* How wide the D2 "adjacent" zone is. Meaningless at the lowest + floor — there, D2 never fires at all. */} + {(config.scope.alertFloor ?? 'adjacent') !== 'excluded_only' && ( +
+ setConfig({ + ...config, + scope: { ...config.scope, proximityBits: Math.min(32, Math.max(1, parseInt(v) || 24)) } + })} + /> +

{t('settings.proximityBitsHint')}

+
+ )} +
+ + setConfig({ ...config, scope: { ...config.scope, targets: items } })} + placeholder={t('settings.targetsPlaceholder')} + /> + + + setConfig({ ...config, scope: { ...config.scope, excludeTargets: items } })} + placeholder={t('settings.excludePlaceholder')} + /> + + + setConfig({ ...config, scope: { ...config.scope, scopeFile: v } })} + /> +

+ {t('settings.scopeFileHint')} +

+
)} - {tab === 'data' && ( + {tab === 'evidence' && ( <> - -
- {[ - { v: 0, k: 'settings.screenshot.interval.off' }, - { v: 30, k: 'settings.screenshot.interval.30s' }, - { v: 60, k: 'settings.screenshot.interval.60s' }, - { v: 300, k: 'settings.screenshot.interval.5m' } - ].map((opt) => ( - - ))} -
-

{t('settings.screenshot.intervalHint')}

+ + setConfig({ ...config, engagement: { ...config.engagement, id: v } })} /> + setConfig({ ...config, engagement: { ...config.engagement, name: v } })} /> - -
- - v{__APP_VERSION__} -
-

{t('settings.checkUpdateHint')}

+ + setConfig({ ...config, operator: { ...config.operator, id: v } })} /> + setConfig({ ...config, operator: { ...config.operator, name: v } })} /> + + + ) + })} +
+

{t('settings.overlayScaleHint')}

+
+ + +

{t('settings.overlayEmphasizeIpHint')}

+ + -

{t('settings.warnOnViolationHint')}

+

{t('settings.overlayPassThroughHint')}

+ {config.overlay?.passThrough === true && ( +
+ {t('settings.overlayPassThroughOpacity')} + setConfig({ ...config, overlay: { ...config.overlay, passThroughOpacity: parseFloat(e.target.value) } })} + className="accent-red-600 w-40" + /> + {Math.round((config.overlay?.passThroughOpacity ?? 0.4) * 100)}% +
+ )} + + {isMacOS && ( + <> + +

{t('settings.overlayShowInDockHint')}

+ + )} - + setConfig({ ...config, scope: { ...config.scope, targets: items } })} - placeholder={t('settings.targetsPlaceholder')} + label={t('settings.whitelist')} + items={config.network.whitelist} + onChange={(items) => setConfig({ ...config, network: { ...config.network, whitelist: items } })} + placeholder={t('settings.safeIpPlaceholder')} /> - - setConfig({ ...config, scope: { ...config.scope, excludeTargets: items } })} - placeholder={t('settings.excludePlaceholder')} + label={t('settings.blacklist')} + items={config.network.blacklist} + onChange={(items) => setConfig({ ...config, network: { ...config.network, blacklist: items } })} + placeholder={t('settings.exposedIpPlaceholder')} + /> + {/* G-A4: the same two-list machinery, pointed at the INTERNAL + address. No LAN blacklist — "this is my own segment" is what + the profile already says, from the other direction. */} + setConfig({ ...config, network: { ...config.network, lanProfile: items } })} + placeholder={t('settings.lanProfilePlaceholder')} /> +

{t('settings.lanProfileHint')}

- + +
+ +
+ {(['auto', 'dns', 'http'] as const).map((m) => ( + + ))} +
+

{t('settings.ipModeHint')}

+
+ {isMacOS && ( +
+ +

{t('settings.showWifiNameHint')}

+
+ )} setConfig({ ...config, scope: { ...config.scope, scopeFile: v } })} + label={t('settings.checkInterval')} + value={String(config.network.checkInterval)} + onChange={(v) => setConfig({ ...config, network: { ...config.network, checkInterval: parseInt(v) || 60 } })} + type="number" /> -

- {t('settings.scopeFileHint')} -

+

{t('settings.pollingOpsecHint')}

+ setConfig({ ...config, network: { ...config.network, confirmations: Math.max(1, parseInt(v) || 3) } })} + type="number" + /> +

{t('settings.confirmationsHint')}

+ setConfig({ ...config, network: { ...config.network, providers: items } })} + placeholder="https://ip.internal.example/json" + /> +

{t('settings.ipProvidersHint')}

+ + + )} + + {tab === 'advanced' && ( + <> + + + + +

{t('settings.cdpHint')}

+ +
+
+ + + + + + + + + + + +
+ {(Object.keys(LOCALE_LABELS) as Locale[]).map((l) => ( + + ))} +
+
+ + + + +
+ + v{__APP_VERSION__} +
+

{t('settings.checkUpdateHint')}

+
+
)} - {tab === 'plugins' && } + + )}
@@ -667,6 +811,27 @@ function FieldGroup({ title, children }: { title: string; children: React.ReactN ) } +// v0.11.8 (DESIGN-SETTINGS-IA Option B): a self-contained collapsible section +// for the Advanced tab. Each section owns its own open state and starts +// collapsed — the two-level progressive-disclosure cut that keeps rarely +// touched settings one click away without cluttering the four front-door tabs. +function Collapsible({ title, children }: { title: string; children: React.ReactNode }): JSX.Element { + const [open, setOpen] = useState(false) + return ( +
+ + {open &&
{children}
} +
+ ) +} + function Field({ label, value, onChange, type = 'text' }: { label: string; value: string; onChange: (v: string) => void; type?: string }): JSX.Element { @@ -693,7 +858,7 @@ function HooksPanel({ hooks, setHooks, hookLoading, setHookLoading, t }: { const [expanded, setExpanded] = useState(null) useEffect(() => { - (window.redlog as { hooks: { detect: () => Promise } }).hooks.detect().then(setHooks) + window.redlog.hooks.detect().then(setHooks) }, []) const copy = (text: string): void => { @@ -703,12 +868,12 @@ function HooksPanel({ hooks, setHooks, hookLoading, setHookLoading, t }: { const handleToggle = async (hook: HookInfo): Promise => { setHookLoading(hook.id) - const hooksApi = (window.redlog as { hooks: { install: (id: string) => Promise<{ success: boolean; message: string }>; uninstall: (id: string) => Promise<{ success: boolean; message: string }> } }).hooks + const hooksApi = window.redlog.hooks const result = hook.installed ? await hooksApi.uninstall(hook.id) : await hooksApi.install(hook.id) toast(result.message, result.success ? 'success' : 'error') - const updated = await (window.redlog as { hooks: { detect: () => Promise } }).hooks.detect() + const updated = await window.redlog.hooks.detect() setHooks(updated) setHookLoading(null) } @@ -826,12 +991,24 @@ interface PluginView { error?: string } +/** §5 shelved (DESIGN-PRINCIPLES.md): RedLog is a product, not a platform. The + * plugin marketplace (signed registry / publisher trust / one-click trust) is + * zero current demand × high ongoing cost × negligence-becomes-risk over-build, + * so it is hidden from the default Settings — kept in code, not deleted. Flip to + * true to restore the Marketplace sub-tab once a real distribution need appears. */ +const MARKETPLACE_ENABLED = false + /** v0.9.10: Plugins and Marketplace were separate top-level tabs, but they are * one task — "what do I have" and "what can I get" — and the operator moves * between them constantly while installing something. Sub-tabs keep both a * click away without spending two of the eight top-level slots. */ function PluginsTab({ t }: { t: (key: string, vars?: Record) => string }): JSX.Element { const [sub, setSub] = useState<'installed' | 'marketplace'>('installed') + // §5 shelved: with the marketplace hidden there is only the installed panel, + // so skip the sub-tab bar entirely and show it directly (see MARKETPLACE_ENABLED). + if (!MARKETPLACE_ENABLED) { + return + } return ( <>
@@ -1168,7 +1345,7 @@ function ExportBundlePanel({ t }: { t: (key: string, vars?: Record string }): JSX.Element { +function IntegrityPanel({ t }: { t: (key: string, vars?: Record) => string }): JSX.Element { const [anchors, setAnchors] = useState([]) const [busy, setBusy] = useState(false) const [verifyMsg, setVerifyMsg] = useState(null) @@ -1515,7 +1692,7 @@ function DeconflictionPanel({ }): JSX.Element { const dc = config.deconfliction ?? { enabled: false, url: '', secret: '', events: ['marker', 'system', 'credential_use', 'c2_checkin'], - subtypes: ['scope_violation'], includeData: false + subtypes: ['scope_violation'], includeData: false, authorityFloor: 'inferred' as const } const [secretVisible, setSecretVisible] = useState(false) const [testing, setTesting] = useState(false) @@ -1592,6 +1769,16 @@ function DeconflictionPanel({ /> {t('settings.deconflictionIncludeData')} + +

{t('settings.deconflictionFactOnlyHint')}

) })}
-
+
+ {/* Collapse toggle (DESIGN-SYSTEM §6.3) — icon-only ↔ labelled. */} +
diff --git a/src/renderer/src/components/SplitPane.tsx b/src/renderer/src/components/SplitPane.tsx new file mode 100644 index 00000000..c3159173 --- /dev/null +++ b/src/renderer/src/components/SplitPane.tsx @@ -0,0 +1,155 @@ +import { useState, useRef, useEffect, useLayoutEffect, type ReactNode } from 'react' +import { splitPaneClamp } from '../lib/splitPane' + +// A reusable two-pane split with a draggable divider (DESIGN-SYSTEM.md §6.2). +// Generalises the Timeline detail-panel resize so any "list | detail" or +// "top / bottom" view can let the operator set the proportion. The FIRST child +// is the resizable pane (its px size persists per `id`); the SECOND fills the +// rest. All bounds go through the pure, tested `splitPaneClamp` seam. +// +// +// // resizable +// // fills remainder +// + +interface SplitPaneProps { + /** Stable id — the size persists to localStorage as `redlog-split-{id}`. */ + id: string + /** horizontal = left|right (resize the first pane's width); vertical = top/bottom (height). */ + direction?: 'horizontal' | 'vertical' + /** Resting size of the resizable (first) pane: px when > 1, or a fraction of + * the container when in (0, 1] (e.g. 0.5 = half). */ + defaultSize: number + min?: number + max?: number + /** px the OTHER pane must always keep. */ + otherMin?: number + children: [ReactNode, ReactNode] + className?: string +} + +const KEY = (id: string): string => `redlog-split-${id}` +const STEP = 16 // keyboard resize step (px) + +export function SplitPane({ + id, + direction = 'horizontal', + defaultSize, + min = 160, + max = 640, + otherMin = 160, + children, + className = '' +}: SplitPaneProps): JSX.Element { + const horizontal = direction === 'horizontal' + const containerRef = useRef(null) + const [size, setSize] = useState(() => { + try { + const saved = parseFloat(localStorage.getItem(KEY(id)) || '') + if (Number.isFinite(saved) && saved > 0) return saved + } catch { /* private mode */ } + return defaultSize + }) + const drag = useRef<{ start: number; startSize: number } | null>(null) + + const containerPx = (): number => { + const el = containerRef.current + if (!el) return Infinity + return horizontal ? el.clientWidth : el.clientHeight + } + + // Re-clamp on mount and whenever the container resizes — a window shrink must + // not leave the resizable pane wider than the container now allows. + useLayoutEffect(() => { + const el = containerRef.current + if (!el) return + // Resolve a fractional default (0 < s ≤ 1) against the measured container the + // first time we can; persisted sizes are always px (≥ min), so this only + // fires for the initial fraction. + const reclamp = (): void => setSize((s) => { + const c = containerPx() + const px = s > 0 && s <= 1 && Number.isFinite(c) ? c * s : s + return splitPaneClamp(px, min, max, c, otherMin) + }) + reclamp() + const ro = new ResizeObserver(reclamp) + ro.observe(el) + return () => ro.disconnect() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [min, max, otherMin, horizontal]) + + const persist = (px: number): void => { + try { localStorage.setItem(KEY(id), String(Math.round(px))) } catch { /* ignore */ } + } + + useEffect(() => { + const onMove = (e: MouseEvent): void => { + const d = drag.current + if (!d) return + const pos = horizontal ? e.clientX : e.clientY + const next = splitPaneClamp(d.startSize + (pos - d.start), min, max, containerPx(), otherMin) + setSize(next) + } + const onUp = (): void => { + if (!drag.current) return + drag.current = null + document.body.style.userSelect = '' + document.body.style.cursor = '' + setSize((s) => { persist(s); return s }) + } + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp) } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [horizontal, min, max, otherMin, id]) + + const onHandleDown = (e: React.MouseEvent): void => { + e.preventDefault() + drag.current = { start: horizontal ? e.clientX : e.clientY, startSize: size } + document.body.style.userSelect = 'none' + document.body.style.cursor = horizontal ? 'col-resize' : 'row-resize' + } + + const reset = (): void => { + setSize(defaultSize) + try { localStorage.removeItem(KEY(id)) } catch { /* ignore */ } + } + + const onHandleKey = (e: React.KeyboardEvent): void => { + const grow = horizontal ? e.key === 'ArrowRight' : e.key === 'ArrowDown' + const shrink = horizontal ? e.key === 'ArrowLeft' : e.key === 'ArrowUp' + if (!grow && !shrink) return + e.preventDefault() + setSize((s) => { + const next = splitPaneClamp(s + (grow ? STEP : -STEP), min, max, containerPx(), otherMin) + persist(next) + return next + }) + } + + const firstStyle = horizontal ? { width: size } : { height: size } + + return ( +
+
+ {children[0]} +
+
+
+ {children[1]} +
+
+ ) +} diff --git a/src/renderer/src/components/StatusBar.tsx b/src/renderer/src/components/StatusBar.tsx index 444fdee7..84290dec 100644 --- a/src/renderer/src/components/StatusBar.tsx +++ b/src/renderer/src/components/StatusBar.tsx @@ -1,12 +1,17 @@ import { useEffect, useState } from 'react' +import { ipBadge } from '../lib/ip-badge' +import { SEVERITY_CLASS, scopeSeverity as scopeSev, worstSeverity, type Severity } from '../lib/alertSeverity' import { useI18n } from '../i18n' import { toast } from './Toast' +import { ICON } from '../lib/icons' export default function StatusBar(): JSX.Element { const [ipStatus, setIpStatus] = useState(null) const [eventCount, setEventCount] = useState(0) const [lootCount, setLootCount] = useState(0) - const [scopeViolations, setScopeViolations] = useState(0) + // G-C1: the severity of what happened, not just how much. `getViolationCount` + // could only ever drive an on/off light. + const [violations, setViolations] = useState>([]) const [uptime, setUptime] = useState(0) const [recording, setRecording] = useState(true) const [stamped, setStamped] = useState(false) @@ -27,14 +32,14 @@ export default function StatusBar(): JSX.Element { window.redlog.ip.getStatus().then(setIpStatus) window.redlog.events.getCount().then(setEventCount) window.redlog.loot.getCount().then(setLootCount) - window.redlog.scope.getViolationCount().then(setScopeViolations) + window.redlog.scope.getViolations().then(setViolations) window.redlog.recording.get().then(setRecording) const unsubIp = window.redlog.ip.onStatus(setIpStatus) const unsubEvent = window.redlog.events.onNew(() => { setEventCount((c) => c + 1) window.redlog.loot.getCount().then(setLootCount) - window.redlog.scope.getViolationCount().then(setScopeViolations) + window.redlog.scope.getViolations().then(setViolations) }) const unsubRec = window.redlog.recording.onChange(setRecording) window.redlog.overlay.isVisible().then(setOverlayVisible) @@ -81,9 +86,26 @@ export default function StatusBar(): JSX.Element { toast(newState ? t('toast.recordingResumed') : t('toast.recordingPaused'), newState ? 'success' : 'warning') } - const safety = ipStatus?.ipSafety ?? 'unknown' - const safetyDot = safety === 'safe' ? 'bg-emerald-500' : safety === 'exposed' ? 'bg-red-500' : 'bg-amber-500' - const safetyLabel = safety === 'safe' ? t('statusBar.safeIp') : safety === 'exposed' ? t('statusBar.exposedIp') : t('statusBar.ipUnknown') + // G-A3: the verdict, plus whether it is still backed by a current reading. + // `qualified` means the badge must not read at full confidence. + const badge = ipBadge(ipStatus) + const scopeViolations = violations.length + const scopeSeverity: Severity = worstSeverity( + violations.map((v) => scopeSev(v.reason as 'excluded_target' | 'adjacent_subnet' | 'adjacent_domain' | 'unrelated')) + ) + // Hollow only when EVERY violation present is an inference — one observed + // rule match among them and the dot must assert. + const scopeInferredOnly = scopeViolations > 0 && violations.every((v) => v.authority === 'inferred') + + const LABEL = { safe: t('statusBar.safeIp'), presumed_safe: t('statusBar.safeIp'), off_profile: t('statusBar.offProfileIp'), exposed: t('statusBar.exposedIp'), unknown: t('statusBar.ipUnknown') } + const sev = SEVERITY_CLASS[badge.severity] + const safetyDot = sev.dot + const safetyLabel = badge.reason === 'stale' ? t('statusBar.ipStale') : LABEL[ipStatus?.ipSafety ?? 'unknown'] + const safetyTitle = badge.reason === 'stale' + ? t('statusBar.staleHint', { n: ipStatus?.consecutiveFailures ?? 0 }) + : badge.reason === 'settling' ? t('statusBar.settlingHint') + : badge.reason === 'presumed' ? t('statusBar.presumedHint') + : ipStatus?.ipSafety === 'off_profile' ? t('statusBar.offProfileHint') : undefined const hours = Math.floor(uptime / 3600) const mins = Math.floor((uptime % 3600) / 60) @@ -138,9 +160,15 @@ export default function StatusBar(): JSX.Element { -
- - +
+ {/* Hollow dot when the reading behind the verdict is not current — a + filled dot is reserved for "this is what we see right now". */} + + {safetyLabel} {ipStatus?.externalIP && ( @@ -151,15 +179,22 @@ export default function StatusBar(): JSX.Element {
- {scopeViolations > 0 ? ( - <> - - {t('statusBar.scopeViolations', { count: scopeViolations })} - - ) : ( + {scopeViolations > 0 ? (() => { + // G-C1: this used to be `count > 0 ? red : green`, so a proximity + // near-miss shouted exactly as loudly as touching a forbidden host. + // Worst-wins, and the dot is hollow when every violation present is + // an inference (§3) — a solid dot asserts. + const s = SEVERITY_CLASS[scopeSeverity] + return ( + <> + + {t('statusBar.scopeViolations', { count: scopeViolations })} + + ) + })() : ( <> - - {t('statusBar.scopeOk')} + + {t('statusBar.scopeOk')} )}
@@ -167,7 +202,7 @@ export default function StatusBar(): JSX.Element {
- 0 ? 'text-amber-400/80' : 'text-zinc-600'}>◆ + 0 ? 'text-amber-400/80' : 'text-zinc-600'}>{ICON.loot} 0 ? 'text-amber-400/80' : 'text-zinc-600'}> {t('statusBar.loot', { count: lootCount })} diff --git a/src/renderer/src/components/TargetView.tsx b/src/renderer/src/components/TargetView.tsx deleted file mode 100644 index 6f7b99d8..00000000 --- a/src/renderer/src/components/TargetView.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import { useState, useEffect, useCallback } from 'react' -import { useI18n } from '../i18n' - -interface TargetEntry { - target: string - commands: string[] - firstSeen: number - lastSeen: number - inScope: boolean | null - eventCount: number -} - -// Match a target string against a scope pattern (subset of scope-monitor's rules -// — enough for UI classification, not enforcement). Handles wildcard subdomains -// (`*.example.com`) and CIDR (`10.0.0.0/24`). Anything else is exact-match. -function ipToLong(ip: string): number { - return ip.split('.').reduce((acc, o) => (acc << 8) + parseInt(o), 0) >>> 0 -} -function matchesScope(target: string, pattern: string): boolean { - if (pattern.startsWith('*.')) { - const bare = pattern.slice(2) - return target === bare || target.endsWith('.' + bare) - } - if (pattern.includes('/')) { - const [net, bits] = pattern.split('/') - if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(target)) return false - const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0 - return (ipToLong(target) & mask) === (ipToLong(net) & mask) - } - return target === pattern -} - -export function TargetView(): JSX.Element { - const [targets, setTargets] = useState([]) - const [filter, setFilter] = useState<'all' | 'in_scope' | 'out_scope'>('all') - const [selected, setSelected] = useState(null) - const [evidence, setEvidence] = useState([]) - // Scope target list from project config — used to compute the inScope column - // on each target. Empty when config isn't set (in which case every target - // shows as "in-scope" since there's no rule to violate). - const [scopeTargets, setScopeTargets] = useState([]) - const { t } = useI18n() - - useEffect(() => { - // Refresh both on mount and whenever the operator saves settings — the - // auto-save (v0.6.21) doesn't broadcast, but any nav back to this view - // will re-mount and pick up the current config. - window.redlog.config.get().then((c) => { - const cfg = c as { scope?: { targets?: string[] } } | null - setScopeTargets(cfg?.scope?.targets ?? []) - }).catch(() => {}) - loadTargets() - const unsub = window.redlog.events.onNew((evt) => { - if (evt.data?.detectedTarget) loadTargets() - }) - return unsub - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - // Reclassify existing targets whenever scope config changes (e.g. operator - // added a scope entry after seeing an out-of-scope hit). - useEffect(() => { if (targets.length > 0) loadTargets() }, [scopeTargets]) - - async function loadTargets(): Promise { - // Include every event type that carries detectedTarget in data — not only - // shell (audit finding #34). Loot / http_navigation / screenshots can all - // reference targets and were invisible here before. - const events = await window.redlog.events.query({ limit: 1000 }) - const map = new Map() - for (const evt of events) { - const tgt = evt.data?.detectedTarget as string | undefined - if (!tgt) continue - const existing = map.get(tgt) - if (existing) { - existing.commands.push(evt.data.command as string) - existing.lastSeen = Math.max(existing.lastSeen, evt.timestamp) - existing.firstSeen = Math.min(existing.firstSeen, evt.timestamp) - existing.eventCount++ - } else { - map.set(tgt, { - target: tgt, - commands: [evt.data.command as string], - firstSeen: evt.timestamp, - lastSeen: evt.timestamp, - inScope: null, - eventCount: 1 - }) - } - } - // Classify each target as in-scope / out-of-scope based on the current - // scope config (audit finding #33 — before this the field was set to null - // and both filter chips returned empty). If scope is unset, treat every - // target as in-scope (no rule to violate). - const list = Array.from(map.values()).sort((a, b) => b.lastSeen - a.lastSeen) - for (const entry of list) { - if (scopeTargets.length === 0) { entry.inScope = true; continue } - entry.inScope = scopeTargets.some((p) => matchesScope(entry.target, p)) - } - setTargets(list) - } - - const loadEvidence = useCallback(async (target: string) => { - if (selected === target) { - setSelected(null) - setEvidence([]) - return - } - setSelected(target) - const allEvents = await window.redlog.events.query({ limit: 500 }) - const filtered = allEvents.filter((e) => { - if (e.targetId === target) return true - if (e.data?.detectedTarget === target) return true - // Scope violations are agent_type='system' with subtype='scope_violation'; - // the prior code checked agent_type='scope_violation' which never matched - // (audit finding P0 #4), so this target's scope hits were invisible. - if (e.agentType === 'system' && e.data?.subtype === 'scope_violation' && e.data?.target === target) return true - return false - }) - setEvidence(filtered.sort((a, b) => b.timestamp - a.timestamp)) - }, [selected]) - - const filtered = targets.filter((tgt) => { - if (filter === 'in_scope') return tgt.inScope === true - if (filter === 'out_scope') return tgt.inScope === false - return true - }) - - const agentIcon: Record = { - shell: 'T', screenshot: 'S', clipboard: 'C', - file_transfer: 'F', marker: 'M', loot: 'L', system: '!' - } - - const agentColor: Record = { - shell: 'text-green-400', screenshot: 'text-blue-400', clipboard: 'text-yellow-400', - file_transfer: 'text-purple-400', marker: 'text-red-400', loot: 'text-orange-400', system: 'text-zinc-400' - } - - return ( -
-
-
-

{t('targets.title', { count: targets.length })}

-

{t('targets.subtitle')}

-
-
- {(['all', 'in_scope', 'out_scope'] as const).map((f) => ( - - ))} -
-
- - {filtered.length === 0 ? ( -

{t('targets.empty')}

- ) : ( -
- {filtered.map((tgt) => ( -
-
loadEvidence(tgt.target)} - className={`bg-zinc-900 border rounded-lg p-3 cursor-pointer transition-colors ${ - selected === tgt.target ? 'border-red-600' : 'border-zinc-800 hover:border-zinc-700' - }`} - > -
- {tgt.target} -
- {t('targets.cmds', { count: tgt.eventCount })} - {tgt.inScope === false && ( - <> - {t('targets.out')} - - - )} - {selected === tgt.target ? '▾' : '▸'} -
-
-
- {t('targets.first', { time: new Date(tgt.firstSeen).toLocaleTimeString() })} · {t('targets.last', { time: new Date(tgt.lastSeen).toLocaleTimeString() })} -
-
- - {selected === tgt.target && ( -
- {evidence.length === 0 ? ( -

{t('targets.noEvidence')}

- ) : ( - <> -
- {Object.entries( - evidence.reduce>((acc, e) => { - acc[e.agentType] = (acc[e.agentType] || 0) + 1 - return acc - }, {}) - ).map(([type, count]) => ( - - {type}: {count} - - ))} -
- {evidence.slice(0, 20).map((e) => ( -
- - {agentIcon[e.agentType] || '?'} - - - {new Date(e.timestamp).toLocaleTimeString()} - - - {e.agentType === 'shell' && (e.data.command as string)} - {e.agentType === 'screenshot' && `Screenshot: ${e.data.filename as string}`} - {e.agentType === 'clipboard' && `Clipboard: ${(e.data.content as string)?.slice(0, 60) || ''}`} - {e.agentType === 'file_transfer' && `${e.data.direction}: ${e.data.filename || e.data.localPath || e.data.remotePath}`} - {e.agentType === 'marker' && `[${e.data.severity}] ${e.data.title}`} - {e.agentType === 'loot' && (() => { const m = (e.data.matches as Array<{ type: string; confidence: string }>)?.[0]; return m ? `Loot: ${m.type.replace(/_/g, ' ')} (${m.confidence})` : `Loot: ${e.data.count ?? 0} detected` })()} - {!['shell', 'screenshot', 'clipboard', 'file_transfer', 'marker', 'loot'].includes(e.agentType) && JSON.stringify(e.data).slice(0, 80)} - -
- ))} - {evidence.length > 20 && ( -

{t('targets.andMore', { count: evidence.length - 20 })}

- )} - - )} -
- )} -
- ))} -
- )} -
- ) -} diff --git a/src/renderer/src/components/TerminalView.tsx b/src/renderer/src/components/TerminalView.tsx index 834553db..30808d81 100644 --- a/src/renderer/src/components/TerminalView.tsx +++ b/src/renderer/src/components/TerminalView.tsx @@ -5,20 +5,31 @@ import { SearchAddon } from '@xterm/addon-search' import '@xterm/xterm/css/xterm.css' import { useI18n } from '../i18n' import { confirm } from './ConfirmDialog' +import { ICON } from '../lib/icons' +import { SplitPane } from './SplitPane' -interface Tab { +// A single shell = one pty session (id) + its live state. cwd basename + +// last-command exit code come from the shell hook via `redlog:` OSC 6 escapes +// that xterm.js dispatches (audit #16). +interface Pane { id: string - label: string pid: number alive: boolean - // cwd basename + last-command exit code — audit #16. Both come from the - // shell hook via `redlog:` OSC 6 escapes that xterm.js dispatches; we set - // them from TerminalPane so the tab bar shows where a shell is + whether - // the last thing that ran failed. cwd?: string lastExit?: number } +// A tab holds 1..N panes shown side by side (horizontal split). The tab's `id` +// is a stable identity separate from any pty, so restarting a dead pane (which +// swaps the pty id) never reshuffles tabs. `activePaneId` is the pane that +// search / font-size / focus act on. +interface Tab { + id: string + label: string + panes: Pane[] + activePaneId: string +} + let tabCounter = 0 const FONT_SIZE_KEY = 'redlog-terminal-fontsize' @@ -42,33 +53,81 @@ export default function TerminalView(): JSX.Element { const addTab = useCallback(() => { const n = ++tabCounter - const id = `term-${Date.now()}-${n}` // Monotonic counter — using tabs.length + 1 caused collisions after close // ("Shell 2" reused if tab 3 was closed before adding a new one). Audit P1 #16. + const paneId = `term-${Date.now()}-${n}` const label = `${t('terminal.shell')} ${n}` - const tab: Tab = { id, label, pid: 0, alive: true } + const tab: Tab = { id: `tab-${Date.now()}-${n}`, label, panes: [{ id: paneId, pid: 0, alive: true }], activePaneId: paneId } setTabs((prev) => [...prev, tab]) - setActiveTab(id) + setActiveTab(tab.id) }, [t]) - const closeTab = useCallback(async (id: string) => { - // Terminal panes host live PTY sessions (with shell hook capturing every - // command); closing kills the child process and loses the interactive - // state. Confirm so a stray middle-click doesn't drop work (audit P1 #17). - const tab = tabs.find((t) => t.id === id) - if (tab?.alive) { + // Split the active tab horizontally: add a second pane with its own pty. + // Capped at 2 — enough to "watch one shell while driving another"; more panes + // would need nested SplitPanes. + const splitActive = useCallback(() => { + setTabs((prev) => prev.map((tb) => { + if (tb.id !== activeTab || tb.panes.length >= 2) return tb + const paneId = `term-${Date.now()}-${++tabCounter}` + return { ...tb, panes: [...tb.panes, { id: paneId, pid: 0, alive: true }], activePaneId: paneId } + })) + }, [activeTab]) + + // Terminal panes host live PTY sessions (shell hook capturing every command); + // closing kills the child process and loses interactive state, so confirm a + // live pane (audit P1 #17). Closing a tab's last pane drops the tab. + const closePane = useCallback(async (tabId: string, paneId: string) => { + const tab = tabs.find((tb) => tb.id === tabId) + if (!tab) return + const pane = tab.panes.find((p) => p.id === paneId) + if (pane?.alive) { const ok = await confirm(t('terminal.closeTitle'), t('terminal.closeConfirm', { label: tab.label }), true) if (!ok) return } - window.redlog.terminal.kill(id) + window.redlog.terminal.kill(paneId) + paneSearchRefs.current.delete(paneId) setTabs((prev) => { - const next = prev.filter((t) => t.id !== id) - if (activeTab === id) { - setActiveTab(next.length > 0 ? next[next.length - 1].id : null) - } + const next = prev.flatMap((tb) => { + if (tb.id !== tabId) return [tb] + const panes = tb.panes.filter((p) => p.id !== paneId) + if (panes.length === 0) return [] + return [{ ...tb, panes, activePaneId: tb.activePaneId === paneId ? panes[panes.length - 1].id : tb.activePaneId }] + }) + if (!next.some((tb) => tb.id === activeTab)) setActiveTab(next.length ? next[next.length - 1].id : null) + return next + }) + }, [tabs, activeTab, t]) + + const closeTab = useCallback(async (tabId: string) => { + const tab = tabs.find((tb) => tb.id === tabId) + if (!tab) return + if (tab.panes.some((p) => p.alive)) { + const ok = await confirm(t('terminal.closeTitle'), t('terminal.closeConfirm', { label: tab.label }), true) + if (!ok) return + } + tab.panes.forEach((p) => { window.redlog.terminal.kill(p.id); paneSearchRefs.current.delete(p.id) }) + setTabs((prev) => { + const next = prev.filter((tb) => tb.id !== tabId) + if (activeTab === tabId) setActiveTab(next.length ? next[next.length - 1].id : null) return next }) - }, [activeTab, tabs, t]) + }, [tabs, activeTab, t]) + + // Restart a dead pane in place: swap its pty id for a fresh one so React + // remounts a new TerminalPane at the same slot (audit #12), keeping tab order. + const restartPane = useCallback((tabId: string, paneId: string) => { + const newId = `term-${Date.now()}-r${++tabCounter}` + paneSearchRefs.current.delete(paneId) + setTabs((prev) => prev.map((tb) => tb.id !== tabId ? tb : { + ...tb, + panes: tb.panes.map((p) => p.id === paneId ? { id: newId, pid: 0, alive: true } : p), + activePaneId: tb.activePaneId === paneId ? newId : tb.activePaneId + })) + }, []) + + const setActivePane = useCallback((tabId: string, paneId: string) => { + setTabs((prev) => prev.map((tb) => tb.id === tabId && tb.activePaneId !== paneId ? { ...tb, activePaneId: paneId } : tb)) + }, []) const didInit = useRef(false) useEffect(() => { @@ -78,34 +137,37 @@ export default function TerminalView(): JSX.Element { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - // Watch shell events → update tab cwd + last exit code (audit #16). Every - // command_end from a builtin terminal carries source + terminalId + exit + - // cwd; we match by terminalId and stamp the label. Cheap because command_end - // is at most one per prompt. + // Watch shell events → update the matching pane's cwd + last exit code + // (audit #16). Every command_end from a builtin terminal carries source + + // terminalId + exit + cwd; match the pane by terminalId across all tabs. useEffect(() => { return window.redlog.events.onNew((evt) => { if (evt.agentType !== 'shell') return const d = evt.data as { subtype?: string; source?: string; terminalId?: string; cwd?: string; exit_code?: number } if (d.source !== 'builtin-terminal' || d.subtype !== 'command_end' || !d.terminalId) return - setTabs((prev) => prev.map((tab) => tab.id === d.terminalId - // Split on either separator so a Windows `C:\Users\foo\proj` doesn't - // render as one giant tab label. Audit P1-3 (WINDOWS_COMPAT_AUDIT.md). - ? { ...tab, cwd: d.cwd?.split(/[\\/]/).pop() || tab.cwd, lastExit: d.exit_code } - : tab)) + setTabs((prev) => prev.map((tab) => ({ + ...tab, + panes: tab.panes.map((p) => p.id === d.terminalId + // Split on either separator so a Windows `C:\Users\foo\proj` doesn't + // render as one giant label. Audit P1-3 (WINDOWS_COMPAT_AUDIT.md). + ? { ...p, cwd: d.cwd?.split(/[\\/]/).pop() || p.cwd, lastExit: d.exit_code } + : p) + }))) }) }, []) - // Tab-nav shortcuts scoped to the Terminal view. Audit finding #78: ⌘T new, - // ⌘W close active, ⌘⇧] next, ⌘⇧[ prev. Bound at window scope but active - // only while this component is mounted (view === 'terminal' in App.tsx). + // Tab/pane shortcuts scoped to the Terminal view. ⌘T new tab, ⌘D split, + // ⌘W close active pane (drops the tab if last), ⌘⇧] / ⌘⇧[ next/prev tab. useEffect(() => { const onKeyDown = (e: KeyboardEvent): void => { const cmd = e.ctrlKey || e.metaKey if (!cmd) return if (e.key === 't' && !e.shiftKey && !e.altKey) { e.preventDefault(); addTab() } + else if (e.key === 'd' && !e.shiftKey && !e.altKey) { e.preventDefault(); splitActive() } else if (e.key === 'w' && !e.shiftKey && !e.altKey) { e.preventDefault() - if (activeTab) closeTab(activeTab) + const tab = tabs.find((tb) => tb.id === activeTab) + if (tab) closePane(tab.id, tab.activePaneId) } else if (e.shiftKey && (e.key === ']' || e.key === '}')) { e.preventDefault() @@ -118,65 +180,99 @@ export default function TerminalView(): JSX.Element { } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) - }, [activeTab, addTab, closeTab]) + }, [activeTab, addTab, splitActive, closePane, tabs]) + + const activeTabObj = tabs.find((tb) => tb.id === activeTab) + const activePaneId = activeTabObj?.activePaneId ?? null + const canSplit = (activeTabObj?.panes.length ?? 0) === 1 + + // One pane slot: the xterm, kept mounted even when dead (so its final output + // stays readable) with a restart overlay; plus a close-pane control and an + // active-pane ring when the tab is split. A mousedown makes this the pane that + // search / font-size act on. + const renderPaneSlot = (tab: Tab, pane: Pane, isSplit: boolean): JSX.Element => { + const isActivePane = activeTab === tab.id && tab.activePaneId === pane.id + return ( +
setActivePane(tab.id, pane.id)} + > + setSearchOpen(true)} + onPid={(pid) => setTabs((prev) => prev.map((tb) => tb.id === tab.id ? { ...tb, panes: tb.panes.map((p) => p.id === pane.id ? { ...p, pid } : p) } : tb))} + onExit={() => setTabs((prev) => prev.map((tb) => tb.id === tab.id ? { ...tb, panes: tb.panes.map((p) => p.id === pane.id ? { ...p, alive: false } : p) } : tb))} + onSearchAddon={(addon) => { paneSearchRefs.current.set(pane.id, addon) }} + /> + {!pane.alive && ( + + )} + {isSplit && ( + + )} +
+ ) + } return (
{/* Tab bar */}
- {tabs.map((tab) => ( -
setActiveTab(tab.id)} - className={`group flex items-center gap-1.5 px-3 h-7 rounded-md text-[11px] cursor-pointer transition-colors ${ - activeTab === tab.id - ? 'bg-[#1a1a1a] text-zinc-200' - : 'text-zinc-500 hover:text-zinc-300 hover:bg-white/[0.03]' - }`} - > - - {tab.label} - {tab.cwd && tab.alive && ( - ~/{tab.cwd} - )} - {tab.alive && tab.lastExit !== undefined && tab.lastExit !== 0 && ( - ✕{tab.lastExit} - )} - {tab.pid > 0 && tab.alive && ( - {tab.pid} - )} - {!tab.alive && ( - - )} - -
- ))} + + {tab.label} + {tab.panes.length > 1 && ( + ◫{tab.panes.length} + )} + {activePane?.cwd && activePane.alive && ( + ~/{activePane.cwd} + )} + {activePane?.alive && activePane.lastExit !== undefined && activePane.lastExit !== 0 && ( + ✕{activePane.lastExit} + )} + {activePane && activePane.pid > 0 && activePane.alive && ( + {activePane.pid} + )} + +
+ ) + })} + {/* Font-size + search on the right — audit findings #14 (SearchAddon) and #15 (font size adjustable). */} @@ -207,7 +312,7 @@ export default function TerminalView(): JSX.Element { className={`w-6 h-6 rounded flex items-center justify-center hover:bg-white/[0.03] text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-500 ${searchOpen ? 'text-zinc-200 bg-white/[0.05]' : 'text-zinc-600 hover:text-zinc-300'}`} title={t('terminal.searchToggle')} aria-label={t('terminal.searchToggle')} - >⌕ + >{ICON.search}
@@ -219,7 +324,7 @@ export default function TerminalView(): JSX.Element { value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} onKeyDown={(e) => { - const addon = activeTab ? paneSearchRefs.current.get(activeTab) : null + const addon = activePaneId ? paneSearchRefs.current.get(activePaneId) : null if (!addon) return if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? addon.findPrevious(searchQuery) : addon.findNext(searchQuery) } if (e.key === 'Escape') { e.preventDefault(); setSearchOpen(false); setSearchQuery('') } @@ -228,20 +333,20 @@ export default function TerminalView(): JSX.Element { className="flex-1 bg-zinc-900 border border-zinc-800 rounded px-2 py-1 text-xs text-zinc-200 font-mono focus:outline-none focus:border-red-500/40" /> @@ -256,15 +361,14 @@ export default function TerminalView(): JSX.Element { className="absolute inset-0" style={{ display: activeTab === tab.id ? 'block' : 'none' }} > - setSearchOpen(true)} - onPid={(pid) => setTabs((prev) => prev.map((t) => t.id === tab.id ? { ...t, pid } : t))} - onExit={() => setTabs((prev) => prev.map((t) => t.id === tab.id ? { ...t, alive: false } : t))} - onSearchAddon={(addon) => { paneSearchRefs.current.set(tab.id, addon) }} - /> + {tab.panes.length === 1 ? ( + renderPaneSlot(tab, tab.panes[0], false) + ) : ( + + {renderPaneSlot(tab, tab.panes[0], true)} + {renderPaneSlot(tab, tab.panes[1], true)} + + )}
))} {tabs.length === 0 && ( diff --git a/src/renderer/src/components/Timeline.tsx b/src/renderer/src/components/Timeline.tsx index 831d3d4c..0d93d8a5 100644 --- a/src/renderer/src/components/Timeline.tsx +++ b/src/renderer/src/components/Timeline.tsx @@ -1,4 +1,5 @@ import { useEffect, useLayoutEffect, useRef, useState, useCallback, useMemo } from 'react' +import { dotShape, isInferredEvent, type DotShape } from '../lib/dotShape' import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import '@xterm/xterm/css/xterm.css' @@ -8,6 +9,13 @@ import { maskEventData, fieldsWithRedactions, type RedactionSpan } from '../lib/ import { LoadingSpinner } from './Feedback' import { getLastVerifyResult, VERIFY_UPDATED_EVENT, type FullVerifyResult } from '../lib/verifyResultCache' import { resolveTimelineKey } from '../lib/timelineKeys' +import { wheelMode } from '../lib/timelineWheel' +import { activeModes, type TimelineModeState } from '../lib/timelineModes' +import { populatedLanes as computePopulatedLanes, visibleLanes as computeVisibleLanes, soloLaneOf } from '../lib/laneVisibility' +import { buildLaneModel } from '../lib/timelineAxis' +import { phaseSegments, phaseMarkersFromEvents } from '../lib/phaseSegments' +import { inferPhaseSuggestions } from '../lib/phaseInference' +import { shortcutsForScope, modKey, MOD_TOKEN } from '../lib/shortcuts' const MIN_LANE_H = 36 const LABEL_W = 92 @@ -60,6 +68,20 @@ const EXTERNAL_ONLY_LANES: Set = new Set(['credential_use', 'c2_checkin' // findings amber → red credential_use, c2, marker, loot, // cleanup, scope // plumbing zinc system +// Phase C step 4: colours for the phase ribbon. Keyed by the lowercase phase +// ids the seams emit (operator markers + inferred). Unknown phases fall back to +// a neutral slate so a custom operator phase still renders. +const PHASE_COLORS: Record = { + recon: '#38bdf8', + delivery: '#a78bfa', + exploit: '#f472b6', + 'credential-access': '#fbbf24', + 'lateral-movement': '#34d399', + exfil: '#fb7185', + 'anti-forensics': '#f87171' +} +const phaseColor = (phase: string): string => PHASE_COLORS[phase] ?? '#94a3b8' + const LANE_COLORS: Record = { // execution shell: '#5ecf9c', @@ -279,6 +301,35 @@ function toLane(agentType: string, subtype?: string, pluginTypes?: PluginEventTy return 'system' } +// A concurrent *instance* within a lane — the thing an operator needs to tell +// apart when several of the same source run at once: two open terminals, two +// agent sessions. Generalized so the axis distinction isn't hardcoded to +// terminals; any source with a persistent per-session identity can join by +// returning a stable key here. HTTP/scanner flows are deliberately excluded — +// each request is its own flow_id, so they aren't a persistent instance and +// would badge every dot. Returns null when the event has no such identity. +function instanceOf(e: RedLogEvent): { lane: string; key: string; kind: string } | null { + const d = e.data ?? {} + if (e.agentType === 'shell') { + const tid = d.terminalId ?? d.terminal_id + if (typeof tid === 'string' && tid) return { lane: 'shell', key: `t:${tid}`, kind: 'term' } + // External (hook) shells carry no terminalId; the shell process pid is a + // stable per-window identity, so concurrent external shells still separate. + if (typeof d.pid === 'number' || (typeof d.pid === 'string' && d.pid)) return { lane: 'shell', key: `p:${d.pid}`, kind: 'sh' } + return null + } + if (e.agentType === 'agent') { + const sid = d.session_id + if (typeof sid === 'string' && sid) return { lane: 'agent', key: `s:${sid}`, kind: 'session' } + return null + } + return null +} + +// Stable colours for instance ordinals (#1, #2, …). Chosen to read against the +// dark track and to not collide with the lane hues they sit on top of. +const INSTANCE_COLORS = ['#38bdf8', '#f472b6', '#a3e635', '#fbbf24', '#c084fc', '#fb7185', '#2dd4bf', '#f97316'] + interface PluginEventType { agentType: string label: string @@ -551,33 +602,21 @@ function ioMark(e: RedLogEvent): { io: IoMark; fail: boolean } { * * Encoded as SHAPE rather than more colour: eighteen lane hues are already * past reliable discrimination, and shape survives both a colour-blind - * operator and a glance at the far edge of the screen. - * - * scope violation diamond out of bounds is categorical - * critical marker ring (hollow) reads as an outline, not a fill - * important marker larger circle - * everything else circle + * operator and a glance at the far edge of the screen. The shape table itself + * now lives in `lib/dotShape.ts` (moved there so the §3 solid-vs-dashed rule + * it carries is testable); this function is the text half of the same glyph. */ -type DotShape = 'circle' | 'diamond' | 'ring' -function dotShape(e: RedLogEvent): { shape: DotShape; scale: number } { - const sub = e.data?.subtype as string | undefined - if (e.agentType === 'system' && sub === 'scope_violation') return { shape: 'diamond', scale: 1.25 } - if (e.agentType === 'marker') { - const sev = String(e.data?.severity ?? 'info') - if (sev === 'critical') return { shape: 'ring', scale: 1.5 } - if (sev === 'important') return { shape: 'circle', scale: 1.25 } - } - return { shape: 'circle', scale: 1 } -} - function shapeTitle(e: RedLogEvent, t: (k: string) => string): string { const sub = e.data?.subtype as string | undefined - if (e.agentType === 'system' && sub === 'scope_violation') return ` · ${t('timeline.shape.scopeViolation')}` + // The shape carries the §3 tier too — a glyph the operator has to decode from + // stroke style alone is not a label. + const tier = isInferredEvent(e) ? ` · ${t('timeline.authority.inferred')}` : '' + if (e.agentType === 'system' && sub === 'scope_violation') return ` · ${t('timeline.shape.scopeViolation')}${tier}` if (e.agentType === 'marker') { const sev = String(e.data?.severity ?? 'info') - if (sev === 'critical' || sev === 'important') return ` · ${t(`marker.severity.${sev}`)}` + if (sev === 'critical' || sev === 'important') return ` · ${t(`marker.severity.${sev}`)}${tier}` } - return '' + return tier } function ioTitle(m: { io: IoMark; fail: boolean }, t: (k: string) => string): string { @@ -711,6 +750,35 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { if (!collapseAgentTurns) return 0 return rawEvents.filter(isCollapsibleAgentTurn).length }, [rawEvents, collapseAgentTurns]) + // Multi-instance distinction: assign each distinct instance (terminal / agent + // session) a stable 1-based ordinal within its lane, and remember which lanes + // have more than one active instance. Dots and the detail panel only surface + // the ordinal for those lanes, so a single terminal / session stays unadorned. + const instanceInfo = useMemo(() => { + const perLane = new Map() + for (const e of events) { + const inst = instanceOf(e) + if (!inst) continue + const keys = perLane.get(inst.lane) ?? [] + if (!keys.includes(inst.key)) { keys.push(inst.key); perLane.set(inst.lane, keys) } + } + const ordinal = new Map() + const multiLanes = new Set() + for (const [lane, keys] of perLane) { + if (keys.length > 1) multiLanes.add(lane) + keys.forEach((k, i) => ordinal.set(`${lane}::${k}`, i + 1)) + } + return { ordinal, multiLanes } + }, [events]) + // The instance ordinal + colour for one event, or null when its lane has no + // concurrency worth distinguishing (or the event has no instance identity). + const instanceMark = useCallback((e: RedLogEvent): { ord: number; kind: string; color: string } | null => { + const inst = instanceOf(e) + if (!inst || !instanceInfo.multiLanes.has(inst.lane)) return null + const ord = instanceInfo.ordinal.get(`${inst.lane}::${inst.key}`) + if (!ord) return null + return { ord, kind: inst.kind, color: INSTANCE_COLORS[(ord - 1) % INSTANCE_COLORS.length] } + }, [instanceInfo]) const [selectedEvent, setSelectedEvent] = useState(null) const [allLoaded, setAllLoaded] = useState(false) const [loading, setLoading] = useState(true) @@ -755,6 +823,23 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { // added since v0.6.90 was previously invisible unless a teammate told you. // `?` opens; Escape or click-outside closes. const [showHelp, setShowHelp] = useState(false) + // T2(b): the transient wheel-mode hint. While the lane stack overflows, a + // plain wheel scrolls lanes (the one surprising gesture wheelMode() resolves); + // this flashes an inline hint on the track's right edge whenever overflow is + // live, then fades after a beat of no wheel input. `wheelHintTimer` holds the + // pending fade so a fresh wheel event resets it instead of stacking timers. + const [wheelHintOn, setWheelHintOn] = useState(false) + const wheelHintTimer = useRef(null) + // T1: the persistent interaction legend under the minimap is collapsible; the + // operator's choice is chrome, not engagement state, so it rides a plain + // global key like the session-dividers / collapse-agent prefs (not the + // per-project scoped keys, which carry review state). Default expanded. + const [legendCollapsed, setLegendCollapsed] = useState(() => { + try { return localStorage.getItem('redlog-timeline-legend-collapsed') === '1' } catch { return false } + }) + useEffect(() => { + try { localStorage.setItem('redlog-timeline-legend-collapsed', legendCollapsed ? '1' : '0') } catch { /* ignore */ } + }, [legendCollapsed]) // Layer 3 (four-layer redaction): raw text of an event's redacted spans is // hidden by default in the detail view. The reviewer opts into a per-event // reveal; each reveal appends a chained system.secret_revealed event so the @@ -1125,21 +1210,99 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { // A lane with no events is dead vertical space — most engagements only ever // touch three or four of them. Keep the filter chip so the operator can see // the lane exists, but don't give it a row until something lands in it. - const populatedLanes = useMemo(() => { - const seen = new Set() - for (const e of events) seen.add(toLane(e.agentType, e.data?.subtype as string | undefined, pluginTypes)) - return seen - // v0.6.95 P1-12: pluginTypes was missing from deps — a plugin-registered - // event lane wouldn't appear on the filter chip until an unrelated state - // change re-ran the memo. Same fix applied to `laneEvents` and - // `recentEvents` below. - }, [events, pluginTypes]) + // Phase C: the lane model is axis-driven (lib/timelineAxis + laneVisibility, + // both unit-tested). 'source' reproduces the former inline behaviour exactly + // (lanes = LANES, grouped by toLane); 'target' groups events by target with an + // untargeted lane last (§9/§12). Default 'source', persisted, so upgrade is a + // no-op until the operator opts in. + const [laneAxis, setLaneAxis] = useState<'source' | 'target'>(() => { + try { return localStorage.getItem('redlog-timeline-lane-axis') === 'target' ? 'target' : 'source' } catch { return 'source' } + }) + useEffect(() => { + try { localStorage.setItem('redlog-timeline-lane-axis', laneAxis) } catch { /* private mode */ } + }, [laneAxis]) + // Phase C step 5: the "Targets" sidebar entry deep-links here by dispatching + // this event (TargetView was removed — the target axis subsumes it). + useEffect(() => { + const onSetAxis = (e: Event): void => { + const detail = (e as CustomEvent).detail + if (detail === 'source' || detail === 'target') setLaneAxis(detail) + } + window.addEventListener('redlog-timeline-set-axis', onSetAxis) + return () => window.removeEventListener('redlog-timeline-set-axis', onSetAxis) + }, []) + // Phase C step 4: the phase ribbon. Off by default until the operator wants it. + const [phaseRibbon, setPhaseRibbon] = useState(() => { + try { return localStorage.getItem('redlog-timeline-phase-ribbon') === '1' } catch { return false } + }) + useEffect(() => { + try { localStorage.setItem('redlog-timeline-phase-ribbon', phaseRibbon ? '1' : '0') } catch { /* private mode */ } + }, [phaseRibbon]) + + // v0.6.95 P1-12: pluginTypes must stay in deps — a plugin-registered event + // lane wouldn't appear until an unrelated re-render. + const srcLaneOf = useCallback( + (e: RedLogEvent): LaneId => toLane(e.agentType, e.data?.subtype as string | undefined, pluginTypes), + [pluginTypes] + ) + const laneModel = useMemo( + () => buildLaneModel( + laneAxis, + events, + LANES.map((id) => ({ id, label: laneLabels[id] })), + srcLaneOf, + t('timeline.untargeted') + ), + [laneAxis, events, laneLabels, srcLaneOf, t] + ) + const laneEvents = laneModel.laneEvents + const axisLanes = laneModel.lanes + // Label + row-marker colour for a lane id, axis-aware. Target lanes have no + // source-type colour, so the row marker falls back to neutral zinc; dots on + // the track stay coloured by source-type (see `dotColor`). + const laneLabelOf = useCallback( + (id: string): string => axisLanes.find((l) => l.id === id)?.label ?? (laneLabels as Record)[id] ?? id, + [axisLanes, laneLabels] + ) + const laneRowColor = (id: string): string => (LANE_COLORS as Record)[id] ?? '#71717a' + + // The laneVisibility seam is generic over `LaneId = string`; here every id + // originates from the LANES tuple, so narrow back to Timeline's LaneId union. + const populatedLanes = useMemo( + () => computePopulatedLanes(events, laneModel.laneOf) as Set, + [events, laneModel] + ) const visibleLanes = useMemo( - () => LANES.filter((l) => populatedLanes.has(l) && !hiddenLanes.has(l)), - [populatedLanes, hiddenLanes] + () => computeVisibleLanes(axisLanes.map((l) => l.id), populatedLanes, hiddenLanes) as LaneId[], + [axisLanes, populatedLanes, hiddenLanes] ) + // T3: the "Active:" row. activeModes() (lib/timelineModes.ts, unit-tested) is + // the single source of which non-default modes are hiding/transforming events; + // this just feeds it the current state and renders the returned chips. A "solo" + // is only claimed when exactly one lane survives AND something is actually + // hidden — a naturally single-lane engagement isn't a solo — so the chip's ✕ + // (showAllLanes) is always a real action. The soloed lane's display label is + // carried as the chip value so the row reads "solo: shell", not the raw id. + const modeChips = useMemo(() => { + const solo = soloLaneOf(visibleLanes, hiddenLanes) + const state: TimelineModeState = { + focusActive: focusChain !== null, + anomalyOnly: anomalyFilter, + collapseAgentTurns, + soloLane: solo ? laneLabelOf(solo) : null, + hiddenLaneCount: hiddenLanes.size, + filterQuery + } + return activeModes(state) + }, [focusChain, anomalyFilter, collapseAgentTurns, visibleLanes, hiddenLanes, filterQuery, laneLabels]) + + // T1/F5: the modifier symbol (⌘ on macOS, Ctrl elsewhere) resolved once from + // the preload-exposed platform, shared by the interaction legend and the `?` + // cheatsheet so both agree with the SHORTCUTS registry's MOD_TOKEN. + const mod = modKey((window as { redlog?: { platform?: string } }).redlog?.platform ?? '') + const laneH = useMemo(() => { if (visibleLanes.length === 0) return MIN_LANE_H const axisH = 28 @@ -1298,6 +1461,26 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { const baseTrackW = Math.max(MIN_BASE_TRACK_W, containerW - LABEL_W) const TRACK_W = Math.min(MAX_TRACK_W, Math.round(baseTrackW * zoom)) const timeSpan = timeEnd - timeStart + + // Phase C step 4: phase ribbon bands. `solid` are AUTHORITATIVE — segments + // between operator phase-markers (§11 乙). `inferred` are SUGGESTIONS — + // segments between the heuristic phase boundaries (§11 甲 / §3), rendered + // dashed and promotable to a real marker on click. Both reuse phaseSegments so + // the seams stay the single source of truth; only computed when the ribbon is on. + const phaseBandsSolid = useMemo( + () => (phaseRibbon ? phaseSegments(phaseMarkersFromEvents(events), timeEnd) : []), + [phaseRibbon, events, timeEnd] + ) + const phaseBandsInferred = useMemo( + () => (phaseRibbon + ? phaseSegments( + inferPhaseSuggestions(events).map((s) => ({ ts: s.ts, phase: s.phase, markerId: s.sourceEventId })), + timeEnd + ) + : []), + [phaseRibbon, events, timeEnd] + ) + // v0.11.6 (AUDIT V7): optional idle-gap compression. // // Time on the track is strictly linear, which is honest but wastes the @@ -1403,11 +1586,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { const fromX = useCallback((px: number) => timeMap.fromX(px), [timeMap]) const totalH = visibleLanes.length * laneH - const laneEvents = useMemo(() => { - const map = Object.fromEntries(LANES.map((l) => [l, [] as RedLogEvent[]])) as Record - for (const e of events) map[toLane(e.agentType, e.data?.subtype as string | undefined, pluginTypes)].push(e) - return map - }, [events, pluginTypes]) + // laneEvents now comes from `laneModel` above (axis-driven, seeded per lane). // Debounced so a held key or a fast typist does not run the scan per // character. 120 ms sits below the point where the filter feels laggy and @@ -1589,8 +1768,10 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { // DOM, which is what makes this affordable. const maxZoom = useMemo(() => { let tightest = Infinity - for (const lane of LANES) { - const evs = laneEvents[lane] + // Iterate the model's lane arrays directly — keyed by source lane or target + // depending on the axis, so a fixed `LANES` walk would miss target lanes and + // index undefined. + for (const evs of Object.values(laneEvents)) { for (let i = 1; i < evs.length; i++) { const d = displayTs(evs[i]) - displayTs(evs[i - 1]) if (d > 0 && d < tightest) tightest = d @@ -1854,7 +2035,26 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { const el = scrollRef.current if (!el) return const onWheel = (e: WheelEvent): void => { - if (e.ctrlKey || e.metaKey) { + // T2: the once-invisible pan/scroll/zoom branch is now the pure wheelMode() + // resolver (lib/timelineWheel.ts, unit-tested). `overflow` reproduces the + // old `scrollHeight > clientHeight + 1` check on the shared vertical-scroll + // parent; the three booleans map 1:1 onto the previous inline conditions so + // behaviour is unchanged — zoom on the zoom modifier, scroll-y only while + // the stack overflows and no modifier escapes it, pan-x everywhere else. + const outer = containerRef.current + const overflow = !!outer && outer.scrollHeight > outer.clientHeight + 1 + const mode = wheelMode({ overflow, shiftKey: e.shiftKey, zoomKey: e.ctrlKey || e.metaKey }) + + // T2(b): flash the inline hint whenever overflow is live so the surprising + // scroll-y gesture explains itself as it happens. Only while overflow — the + // common (fitting) case stays clean — and it fades after a beat of no wheel. + if (overflow) { + setWheelHintOn(true) + if (wheelHintTimer.current != null) window.clearTimeout(wheelHintTimer.current) + wheelHintTimer.current = window.setTimeout(() => setWheelHintOn(false), 2500) + } + + if (mode === 'zoom') { e.preventDefault() setCluster(null) const cursorX = e.clientX - el.getBoundingClientRect().left @@ -1864,15 +2064,13 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { // and felt like nothing happened. Scaling by deltaY makes pinch smooth and // still gives a mouse wheel a reasonable step. setZoom((prev) => Math.min(maxZoomRef.current, Math.max(0.25, prev * Math.exp(-e.deltaY * 0.002)))) + } else if (mode === 'scroll-y') { + // v0.9.4 P0-2: while the lane stack overflows, let the native vertical + // scroll through (do NOT preventDefault) so the lanes clipped below the + // fold are reachable by wheel. Shift is the escape hatch back to pan-x. + return } else if (e.deltaY !== 0) { - // v0.9.4 P0-2: while the lane stack overflows its container, deltaY - // has to drive the vertical axis or the newly-scrollable lanes are - // unreachable by wheel; shift+wheel keeps the horizontal scroll that - // deltaY does otherwise (dragging and the minimap also still pan). - // With no vertical overflow — the common case — behaviour is - // unchanged. - const outer = containerRef.current - if (outer && !e.shiftKey && outer.scrollHeight > outer.clientHeight + 1) return + // pan-x: the common, unsurprising case — deltaY drives the time axis. e.preventDefault() el.scrollLeft += e.deltaY } @@ -1998,6 +2196,22 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { }, []) const showAllLanes = useCallback(() => setHiddenLanes(new Set()), []) + // T3: dispatch a mode chip's ✕ to the existing clear path for that mode. The + // clearAction ids come from activeModes(); each maps to the same setter the + // header/keyboard already uses, so a chip never invents new clearing logic. + // clear-solo and clear-hidden-lanes both just restore every lane (a solo is a + // set of hidden lanes), so both route through showAllLanes. + const clearMode = useCallback((action: string) => { + switch (action) { + case 'clear-focus': setFocusAnchorId(null); break + case 'clear-anomaly': setAnomalyFilter(false); break + case 'clear-collapse-agent': setCollapseAgentTurns(false); break + case 'clear-solo': + case 'clear-hidden-lanes': showAllLanes(); break + case 'clear-filter': setFilterQuery(''); break + } + }, [showAllLanes]) + // One keydown listener for the Timeline's global single-key surface. This // replaced four separate window listeners that each re-implemented the "am I // typing?" guard and independently handled Escape — with the detail panel, @@ -2133,11 +2347,11 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { // Operator / host → drop the value into the filter query (feature 1 dim path // picks it up automatically). const activatePaletteItem = useCallback((item: PaletteItem) => { - if (item.kind === 'event' || item.kind === 'marker') { + if ('value' in item) { + setFilterQuery(item.value) + } else { setSelectedEvent(item.event) scrollToEvent(item.event) - } else { - setFilterQuery(item.value) } setPaletteOpen(false) setPaletteQuery('') @@ -2291,21 +2505,21 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { const isSel = i === paletteIndex return ( ) })}
-
+
{t('timeline.palette.footer')}
@@ -2327,52 +2541,30 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { {t('timeline.help.title')}
-
- {([ - ['timeline.help.group.filter', [ - ['/', 'timeline.help.slash'], - ['⌘K', 'timeline.help.palette'], - ['Alt-click', 'timeline.help.soloLane'], - ['Esc', 'timeline.help.escFilter'] - ]], - ['timeline.help.group.focus', [ - ['f', 'timeline.help.focusChain'], - ['click', 'timeline.help.selectDot'], - ['↑/↓', 'timeline.help.walk'] - ]], - ['timeline.help.group.timeline', [ - ['Right-click', 'timeline.help.dropMarker'], - ['drag minimap', 'timeline.help.zoom'], - ['click cluster', 'timeline.help.expandCluster'] - ]], - ['timeline.help.group.detail', [ - ['click ▶', 'timeline.help.expandBody'], - ['click cause chip', 'timeline.help.jumpCause'], - ['Copy full', 'timeline.help.copyFull'] - ]], - ['timeline.help.group.misc', [ - ['?', 'timeline.help.thisMenu'] - ]] - ] as Array<[string, Array<[string, string]>]>).map(([groupKey, rows]) => ( -
-
{t(groupKey)}
-
- {rows.map(([key, descKey]) => ( -
- {key} - {t(descKey)} -
- ))} + {/* F5: rendered from the shared SHORTCUTS registry (lib/shortcuts.ts) + so this cheatsheet and the Dashboard shortcuts card can no longer + drift. `shortcutsForScope('timeline')` returns the global keys plus + the timeline-only ones; the MOD_TOKEN placeholder in each combo is + swapped for the resolved modKey, and each labelKey maps to a + shortcut.* i18n string. */} +
+
+ {shortcutsForScope('timeline').map((s) => ( +
+ + {s.keys.split(MOD_TOKEN).join(mod)} + + {t(s.labelKey)}
-
- ))} + ))} +
-
+
{t('timeline.help.footer')}
@@ -2392,7 +2584,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { @@ -2417,7 +2609,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { makes the affordance visible; the modal itself lists everything. */} @@ -2426,7 +2618,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: {
@@ -2439,7 +2631,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { >{Math.round(zoom * 100)}% ↺ @@ -2450,6 +2642,27 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { agent subtypes are dropped from the render pipeline — the hidden count is shown so the empty agent lane doesn't look like a bug. Same visual weight as the other filter chips. */} + {/* Phase C: lane axis — group rows by source type (recording view) or + by target (reconstruction view). Source is the default. */} + + + {/* Phase C step 4: phase ribbon toggle. */} + + @@ -2486,7 +2699,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { ⋯ {t('timeline.compressGaps.label')} {compressGaps && timeMap.gaps.length > 0 && ( - {timeMap.gaps.length} + {timeMap.gaps.length} )} ) : null} @@ -2542,7 +2755,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { setFollowMode(true) }} title={isLive ? t('timeline.follow.jumpToNow') : t('timeline.follow.jumpToNow')} - className={`whitespace-nowrap text-[10px] font-mono px-1.5 py-0.5 rounded transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-emerald-500 ${ + className={`whitespace-nowrap text-2xs font-mono px-1.5 py-0.5 rounded transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-emerald-500 ${ isLive ? 'text-emerald-100 bg-emerald-600/40 ring-1 ring-emerald-500/40' : 'text-amber-300 bg-amber-500/10 hover:bg-amber-500/20' @@ -2552,7 +2765,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { onClick={() => setFollowMode((v) => !v)} title={followMode ? t('timeline.follow.pauseHint') : t('timeline.follow.resumeHint')} aria-label={followMode ? t('timeline.follow.pauseHint') : t('timeline.follow.resumeHint')} - className="w-5 h-5 flex items-center justify-center text-[10px] text-zinc-500 hover:text-zinc-200 bg-zinc-800/60 rounded transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-500" + className="w-5 h-5 flex items-center justify-center text-2xs text-zinc-500 hover:text-zinc-200 bg-zinc-800/60 rounded transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-500 hit-target" >{followMode ? '⏸' : '▶'}
) @@ -2579,7 +2792,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { {viewsOpen && (
-
{t('timeline.views.saveNew')}
+
{t('timeline.views.saveNew')}
setViewsName(e.target.value)} @@ -2606,14 +2819,14 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { className="flex-1 text-left text-xs font-mono text-zinc-200 truncate" title={v.name} >{v.name} - + {formatTs(v.createdAt, tz, projectTz, 'time')}
))} @@ -2695,7 +2908,10 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { title={t('timeline.showAllLanes')} >{t('timeline.showAll')} )} - {LANES.map((id) => { + {/* Lane filter chips are source-type specific (colour + label); on the + target axis they're hidden for now (v1 — target lanes are data- + derived and use no palette). */} + {laneAxis === 'source' && LANES.map((id) => { const empty = !populatedLanes.has(id) const hidden = hiddenLanes.has(id) const off = empty || hidden @@ -2729,6 +2945,41 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: {
+ {/* T3: active-modes row. Renders directly under the header, and ONLY when + at least one sticky mode is hiding/transforming events — activeModes() + returns [] at all-default, so a clean timeline shows no bar at all. It + answers "why is the track near-empty?" (DESIGN-PRINCIPLES §8) with one + dismissible chip per mode plus a clear-all. */} + {modeChips.length > 0 && ( +
+ + {t('timeline.mode.active')} + + {modeChips.map((chip) => ( + + {t(chip.labelKey, chip.value != null ? { value: chip.value } : undefined)} + + + ))} + +
+ )} + {/* Density minimap — overview of the whole engagement. Drag to zoom to a window, click to jump. The bright frame marks the current viewport. */}
+ {/* T1: persistent interaction legend. A low-weight strip under the minimap + carrying the three core gestures (pan / zoom / filter) plus a pointer to + the full ? cheatsheet — so pan, zoom and filter are never a secret. The + {mod} token resolves once via modKey(platform), shared with the F5 + registry. Collapsible; the choice persists in legendCollapsed. */} +
+ {!legendCollapsed && ( +
+ {t('timeline.legend.pan')} + {t('timeline.legend.zoom', { mod })} + + +
+ )} + {legendCollapsed && ( + + )} + +
+ {/* Timeline + event list split */} -
+
+ {/* T2(b): the transient wheel-mode hint, pinned to the track's right edge. + Shown only while the lane stack overflows (wheelHintOn is set solely on + an overflow wheel), so the common fitting case stays clean. It names the + surprising gesture — plain wheel scrolls lanes — and the ⇧ escape hatch + back to horizontal pan. pointer-events-none so it never eats a wheel. */} + {wheelHintOn && ( +
+
{t('timeline.wheelHint.scrolling')}
+
{t('timeline.wheelHint.panWith')}
+
+ )} {/* Swim lanes */} {/* v0.9.4 P0-2: scrolls vertically. The lane labels are a sibling of the track, so the overflow has to live on this shared parent — @@ -2772,8 +3073,8 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { className="flex items-center gap-1.5 px-2 border-b border-zinc-800/30 font-mono text-[11px]" style={{ height: laneH }} > - - {laneLabels[id]} + + {laneLabelOf(id)}
))}
@@ -2818,6 +3119,35 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { {axisLabel(ts, i, ticks, timeSpan, tz, projectTz)} ))} + {/* Phase ribbon (step 4). Solid = authoritative operator phase + segments; dashed = inferred suggestions, click to promote to a + real marker (§3/§11). Sits at the bottom edge of the axis row. */} + {phaseRibbon && phaseBandsSolid.map((b) => ( +
+ ))} + {phaseRibbon && phaseBandsInferred.map((b) => ( +
{/* Lanes area */} @@ -2862,7 +3192,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { bleeding out of a 4px band is worse than none. */} {w >= 34 && ( {b.label} )} @@ -2884,7 +3214,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { }} title={t('timeline.gapSkipped', { d: formatGap(g.to - g.from) })} > - + ⋯{formatGap(g.to - g.from)}
@@ -2918,7 +3248,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { // the shape. Clusters keep their own sizing — the popup // lists members individually, so per-event emphasis there // would fight the count glyph. - const marks = single ? dotShape(evt) : { shape: 'circle' as DotShape, scale: 1 } + const marks = single ? dotShape(evt) : { shape: 'circle' as DotShape, scale: 1, inferred: false } const dot = single ? Math.round(9 * marks.scale) : Math.min(24, 13 + Math.round(Math.log2(c.events.length) * 3)) @@ -2974,6 +3304,10 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { // node, and ↑/↓ walks from there — the same model the // existing keyboard handler already implements. const isTabStop = single && (sel || (!selectedEvent && c === visibleClusters[0])) + // Dot colour is always the SOURCE-type colour, even on the + // target axis where c.lane is a target id (no palette entry). + // Multi-event clusters take their first event's source type. + const dotColor = LANE_COLORS[srcLaneOf(c.events[0])] ?? laneRowColor(c.lane) return ( ))} {overflow > 0 && ( @@ -3196,7 +3562,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { <> {/* Drag handle — 4px hit strip along the top edge; visual accent on hover. */}
{ e.preventDefault() @@ -3226,6 +3592,16 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { {operatorLabel(selectedEvent.operatorId)} + {(() => { + const im = instanceMark(selectedEvent) + if (!im) return null + return ( + {im.kind} #{im.ord} + ) + })()}
{(() => { @@ -3403,7 +3779,7 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { Suppressed entirely once the operator is already in focus mode so it doesn't add noise. */} {!focusChain && ( -

+

{t('timeline.focusChain.enterHint')}

)} @@ -3446,7 +3822,11 @@ export default function TimelinePanel({ focusEventId, focusTs, onDropMarker }: { && selectedEvent.data?.subtype === 'command_end' && selectedEvent.data?.source === 'builtin-terminal' && ( - + )} + /> )} {/* Session-level replay: for session_start / session_end, replays the ENTIRE pty session. Critical when the operator ssh'd into @@ -3489,6 +3869,14 @@ function formatBytes(n: number): string { return `${(n / (1024 * 1024)).toFixed(1)} MB` } +// The size of the .cast span a builtin-terminal command produced, when the +// command was bracketed (io.len present and not `unbracketed`). Used to decide +// whether ReplayCommand can cheaply auto-load the output for an inline preview. +function castSpanBytes(data: Record | undefined): number | undefined { + const io = data?.io as { len?: number; unbracketed?: boolean } | undefined + return io && typeof io.len === 'number' && !io.unbracketed ? io.len : undefined +} + // Structured detail body for a shell command_end event. Renders separate // stdout / stderr collapsible sections when the wrapper populated them, // falls back to a "mixed" section for the legacy `output` field, and @@ -3671,6 +4059,73 @@ function AgentTurnDetail({ data }: { data: Record }): JSX.Eleme ) } +/** io_ref sidecar view model on a scanner event (SPEC-IO-SIDECAR.md). Present + * only when the captured body exceeded the inline preview cap, so the full + * bytes live on disk and the event carries just this digest. */ +interface IoRefView { ref: string; len: number; sha256?: string; truncated?: boolean; ct?: string } + +/** A body whose full bytes are in the io/ sidecar: show the truncated inline + * preview immediately, with a "load full body" affordance that pulls the + * complete bytes on demand via `io:read` (bounded by the read cap). An + * over-cap or pruned body says so rather than dumping raw bytes (SPEC step 4). */ +function SidecarBody({ label, preview, io, accent }: { + label: string + preview: string + io: IoRefView + accent: 'emerald' | 'amber' | 'zinc' +}): JSX.Element { + const { t } = useI18n() + const [full, setFull] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const load = async (): Promise => { + setLoading(true); setError(null) + try { + const r = await window.redlog.io.read(io.ref) + if (r.ok && typeof r.text === 'string') setFull(r.text) + else setError(r.maxBytes + ? t('timeline.detail.ioTooLarge', { max: formatBytes(r.maxBytes) }) + : t('timeline.detail.ioUnavailable')) + } catch { + setError(t('timeline.detail.ioUnavailable')) + } finally { + setLoading(false) + } + } + + return ( +
+ + {full === null && ( +
+ + {io.truncated && ( + + {t('timeline.detail.ioCeiling')} + + )} + {error && {error}} +
+ )} +
+ ) +} + /** v0.11.2 (T6): one HTTP exchange as the operator thinks about it — what went * out, what came back. The request and response arrive as two separate chain * events linked by `flow_id`, so each renders the half it holds and names the @@ -3681,6 +4136,9 @@ function ScannerDetail({ data }: { data: Record }): JSX.Element const isResponse = subtype === 'http_response' const params = data.params as Record | undefined const preview = typeof data.response_preview === 'string' ? (data.response_preview as string) : '' + const io = data.io as { request?: IoRefView; response?: IoRefView } | undefined + const reqIo = io?.request + const rspIo = io?.response const contentType = String(data.content_type ?? '') const contentLength = typeof data.content_length === 'number' ? (data.content_length as number) : null @@ -3699,15 +4157,31 @@ function ScannerDetail({ data }: { data: Record }): JSX.Element startOpen /> )} + {/* The request body the mitmproxy addon captures (request_body_preview) was + collected but never shown — only params were. Render it like the + response body so POST/PUT payloads are actually reviewable. */} + {!isResponse && typeof data.request_body_preview === 'string' && (data.request_body_preview as string).length > 0 && ( + reqIo + ? + : + )} {isResponse && preview.length > 0 && ( - preview.length} - accent="emerald" - startOpen - /> + rspIo + ? + : preview.length} + accent="emerald" + startOpen + /> )} {bodyless && (

@@ -3716,7 +4190,7 @@ function ScannerDetail({ data }: { data: Record }): JSX.Element })}

)} - {(data.request_headers || data.response_headers) && ( + {Boolean(data.request_headers || data.response_headers) && ( setOpen((o) => !o)} className="w-full flex items-center gap-2 px-2 py-1 text-left" > - {open ? '▼' : '▶'} + {open ? '▼' : '▶'} {label} - + {formatBytes(shownBytes)} {truncated && ( - + {t('timeline.detail.truncated')} )} @@ -3836,7 +4310,7 @@ function CollapsibleStream({ tabIndex={0} onClick={(e) => { e.stopPropagation(); void copyFull() }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); e.preventDefault(); void copyFull() } }} - className="ml-auto text-[10px] font-mono px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200 cursor-pointer" + className="ml-auto text-2xs font-mono px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200 cursor-pointer" > {copied ? t('timeline.detail.copied') : t('timeline.detail.copyFull')} @@ -3846,7 +4320,7 @@ function CollapsibleStream({
           {preview}
           {isLarge && (
-            
+            
               {t('timeline.detail.previewCut', { shown: formatBytes(preview.length), total: formatBytes(shownBytes) })}
             
           )}
@@ -3876,16 +4350,27 @@ function MetadataGrid({ entries }: { entries: Array<[string, unknown]> }): JSX.E
   )
 }
 
+// Command slices at or below this size auto-load so the operator sees the
+// output inline without a click — the "see I/O on the timeline" affordance.
+// Larger slices (and the heavy session player) stay behind the manual button,
+// so selecting an event never pulls a giant dump into the renderer by itself.
+const REPLAY_AUTOLOAD_LIMIT = 512 * 1024
+// Lines of stdout shown before the "show full output" expander.
+const REPLAY_PREVIEW_LINES = 8
+
 // Pulled from the session's asciinema .cast on disk — not from the event
 // row. Rendering here keeps the chain event clean (command + exit + duration
-// only) while still letting the operator see what actually printed.
-function ReplayCommand({ eventId, mode = 'command' }: { eventId: string; mode?: 'command' | 'session' }): JSX.Element {
+// only) while still letting the operator see what actually printed. For a
+// bracketed command whose slice is small, the output previews automatically;
+// large slices and whole-session replays stay one deliberate click away.
+function ReplayCommand({ eventId, mode = 'command', castBytes }: { eventId: string; mode?: 'command' | 'session'; castBytes?: number }): JSX.Element {
   const [text, setText] = useState(null)
   const [events, setEvents] = useState | null>(null)
   const [truncated, setTruncated] = useState(false)
   const [error, setError] = useState(null)
   const [loading, setLoading] = useState(false)
-  const [expanded, setExpanded] = useState(false)
+  const [loaded, setLoaded] = useState(false)
+  const [showFull, setShowFull] = useState(false)
   const { t } = useI18n()
   const load = async (): Promise => {
     setLoading(true)
@@ -3903,21 +4388,36 @@ function ReplayCommand({ eventId, mode = 'command' }: { eventId: string; mode?:
       const evs = (r as { events?: Array<[number, 'o', string]> }).events
       if (evs && evs.length > 0) setEvents(evs)
       setTruncated(Boolean((r as { truncated?: boolean }).truncated))
-      setExpanded(true)
+      setLoaded(true)
     } finally {
       setLoading(false)
     }
   }
+
+  // A small, bracketed command slice is cheap to read, so auto-load it. The
+  // detail panel is not remounted per selection, so reset state when eventId
+  // changes (otherwise the previous command's output would leak into the new
+  // panel) and re-trigger the auto-load.
+  const autoEligible = mode === 'command' && typeof castBytes === 'number' && castBytes > 0 && castBytes <= REPLAY_AUTOLOAD_LIMIT
+  useEffect(() => {
+    setText(null); setEvents(null); setError(null); setTruncated(false); setShowFull(false); setLoaded(false)
+    if (autoEligible) void load()
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [eventId])
+
   const btnLabel = mode === 'session' ? 'timeline.replay.sessionButton' : 'timeline.replay.button'
-  if (!expanded) {
+  if (!loaded && !loading) {
     return (
       
+      >{t(btnLabel)}
     )
   }
+  if (!loaded && loading) {
+    return 

{t('timeline.replay.loading')}

+ } if (error) return

{t('timeline.replay.failed', { error })}

// Session replays get the full player (xterm + scrubber + speed). If the // slice happened to be empty of frames — legacy casts, empty session — we @@ -3925,11 +4425,24 @@ function ReplayCommand({ eventId, mode = 'command' }: { eventId: string; mode?: if (mode === 'session' && events && events.length > 0) { return } - const heightCls = mode === 'session' ? 'max-h-[400px]' : 'max-h-[200px]' + const full = text ?? '' + const lines = full.split('\n') + const isLong = lines.length > REPLAY_PREVIEW_LINES + 1 || full.length > 800 + const shown = showFull || !isLong ? full : lines.slice(0, REPLAY_PREVIEW_LINES).join('\n') + const heightCls = mode === 'session' || showFull ? 'max-h-[400px]' : 'max-h-[200px]' return ( -
-      {text || t('timeline.replay.empty')}
-    
+
+
+        {shown || t('timeline.replay.empty')}
+      
+ {isLong && ( + + )} + {truncated &&

{t('timeline.replay.truncated')}

} +
) } diff --git a/src/renderer/src/components/TranscriptView.tsx b/src/renderer/src/components/TranscriptView.tsx index e4258802..d70246af 100644 --- a/src/renderer/src/components/TranscriptView.tsx +++ b/src/renderer/src/components/TranscriptView.tsx @@ -1,6 +1,10 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react' import { useI18n } from '../i18n/I18nContext' +import { EmptyState } from './Feedback' +import { ICON } from '../lib/icons' +import { emptyStateFor } from '../lib/emptyState' import { toast } from './Toast' +import { SplitPane } from './SplitPane' /** * v0.11.2 (design note T5): the Timeline read vertically. @@ -192,7 +196,7 @@ function buildBlocks(events: Ev[], names: Record): Block[] { if (e.agentType === 'marker') { out.push({ id: e.id, ts: e.timestamp, kind: 'marker', actor: actorOf(e), - input: `⚑ ${d.title ?? ''}`, output: typeof d.notes === 'string' && d.notes ? (d.notes as string) : undefined, + input: `${ICON.marks} ${d.title ?? ''}`, output: typeof d.notes === 'string' && d.notes ? (d.notes as string) : undefined, meta: String(d.severity ?? ''), events: [e] }) continue @@ -217,8 +221,9 @@ function safeJson(v: unknown): string { try { return JSON.stringify(v, null, 2) } catch { return String(v) } } -export default function TranscriptView({ onOpenInTimeline }: { +export default function TranscriptView({ onOpenInTimeline, onEmptyAction }: { onOpenInTimeline?: (id: string, ts: number) => void + onEmptyAction?: (target: string) => void }): JSX.Element { const { t } = useI18n() const [events, setEvents] = useState([]) @@ -227,6 +232,8 @@ export default function TranscriptView({ onOpenInTimeline }: { const [kinds, setKinds] = useState>(new Set()) const [expanded, setExpanded] = useState>(new Set()) const [loading, setLoading] = useState(true) + // Selected exchange → shown in the detail pane (full input+output, copyable). + const [selectedId, setSelectedId] = useState(null) const bodyRef = useRef(null) const load = useCallback(async () => { @@ -284,6 +291,7 @@ export default function TranscriptView({ onOpenInTimeline }: { }, [shown, t]) const KINDS: Kind[] = ['shell', 'agent-turn', 'agent-tool', 'http', 'marker', 'loot'] + const selected = shown.find((b) => b.id === selectedId) ?? null return (
@@ -301,7 +309,7 @@ export default function TranscriptView({ onOpenInTimeline }: {
-
+
+ +
{loading &&

{t('transcript.loading')}

} - {!loading && shown.length === 0 && ( -

{t('transcript.empty')}

- )} + {!loading && shown.length === 0 && (() => { + const es = emptyStateFor('transcript', { captureDark: false }) + return ( + onEmptyAction?.(es.action!.target) } + : undefined} + /> + ) + })()} {shown.map((b) => { const open = expanded.has(b.id) const big = (b.output?.length ?? 0) > MAX_INLINE const body = open || !big ? b.output : b.output?.slice(0, MAX_INLINE) return ( -
-
+
+
setSelectedId(b.id)} + className="flex items-center gap-2 px-2.5 py-1.5 border-b border-zinc-800/50 cursor-pointer" + title={t('transcript.selectHint')} + > {new Date(b.ts).toLocaleTimeString()} {b.actor} - {b.meta && {b.meta}} + {b.meta && {b.meta}} {onOpenInTimeline && ( @@ -379,6 +403,74 @@ export default function TranscriptView({ onOpenInTimeline }: { ) })}
+ + +
+
+ ) +} + +// The detail pane: the selected exchange's FULL input and output, un-capped and +// individually copyable — complements the feed (which caps output at MAX_INLINE +// and has no copy affordance). SplitPane lets the operator trade feed-scan width +// for detail-read width. +function TranscriptDetail({ block, onOpenInTimeline }: { + block: Block | null + onOpenInTimeline?: (id: string, ts: number) => void +}): JSX.Element { + const { t } = useI18n() + if (!block) { + return ( +
+ {ICON.transcript} + {t('transcript.selectPrompt')} +
+ ) + } + const copy = (v: string): void => { + navigator.clipboard.writeText(v) + .then(() => toast(t('transcript.valueCopied'), 'success')) + .catch(() => toast(t('transcript.copyFailed'), 'error')) + } + const copyBtn = (v: string): JSX.Element => ( + + ) + const label = (text: string): JSX.Element => ( + {text} + ) + return ( +
+
+ + {block.actor} + {new Date(block.ts).toLocaleTimeString()} + {onOpenInTimeline && ( + + )} +
+
+
{label(t('transcript.input'))}{copyBtn(block.input)}
+
{block.input}
+
+ {block.output != null && ( +
+
{label(t('transcript.output'))}{copyBtn(block.output)}
+
{block.output}
+
+ )} + {block.output == null && block.outputNote && ( +

+ {t(`transcript.note.${block.outputNote}`, { size: fmtBytes(block.outputBytes ?? 0) })} +

+ )}
) } diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index b45ab3fa..72ebf69a 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -13,9 +13,21 @@ interface ProjectMeta { interface IPStatus { externalIP: string | null internalIP: string | null - ipSafety: 'safe' | 'exposed' | 'unknown' + ipSafety: 'safe' | 'presumed_safe' | 'off_profile' | 'exposed' | 'unknown' + /** G-A4: the internal address judged against `network.lanProfile`. Only + * 'safe' | 'off_profile' | 'unknown' are reachable — there is no LAN + * blacklist. */ + lanSafety?: 'safe' | 'off_profile' | 'unknown' lastCheck: number error: string | null + /** A new address is being confirmed; the displayed one is the last stable read. */ + settling?: boolean + /** Consecutive failed reads. */ + consecutiveFailures?: number + /** The verdict outlived its reading and has decayed to 'unknown'. */ + stale?: boolean + /** A-6: the address matches both lists — a contradictory config. */ + listConflict?: boolean link?: { type: 'wifi' | 'wired' | 'unknown'; name: string } } @@ -77,6 +89,7 @@ interface RedLogAPI { platform: string app: { checkForUpdates: () => Promise + openExternal: (url: string) => Promise } project: { list: () => Promise @@ -106,6 +119,9 @@ interface RedLogAPI { search: (query: string, limit?: number) => Promise onNew: (cb: (event: RedLogEvent) => void) => () => void onNewBatch: (cb: (events: RedLogEvent[]) => void) => () => void + // Layer 3 (four-layer redaction): logs a chained system.secret_revealed + // audit event whenever the reviewer reveals raw bytes of a redacted span. + logSecretRevealed: (sourceEventId: string, fields: string[]) => Promise<{ ok: boolean; id?: string; error?: string }> } marker: { create: (data: Record) => Promise @@ -115,9 +131,39 @@ interface RedLogAPI { capture: (causeEventId?: string) => Promise } scope: { - getViolations: () => Promise> + getViolations: () => Promise> getViolationCount: () => Promise isConfigured: () => Promise + /** G-D1: the positive half — every target touched and how it classifies. + * Optional like `exportViolations`: a panel must not crash when paired + * with a bridge that predates the method. */ + adherenceSummary?: () => Promise<{ + totals: { targets: number; actions: number; in_scope: number; excluded: number; adjacent_subnet: number; adjacent_domain: number; unrelated: number } + summary: string + scopeChanges: number + /** G-D2: which document the scope came from. Null when the operator typed + * the targets in directly rather than loading an authorisation file. */ + provenance: { + path: string + digest: string + bytes: number + entries: number + modifiedAt: number + loadedAt: number + error?: string + } | null + } | null> + } + io: { + read: (ref: string, off?: number, len?: number) => Promise<{ ok: boolean; text?: string; bytes?: number; error?: string; maxBytes?: number }> } chain: { length: () => Promise @@ -156,14 +202,29 @@ interface RedLogAPI { } data: { exportJson: () => Promise - exportBundle?: () => Promise<{ outDir: string; manifest: unknown } | null> + exportBundle?: (opts?: { profile?: 'internal' | 'client-deliverable'; sanitizeUnknown?: boolean }) => Promise<{ outDir: string; manifest: unknown } | null> exportScopeFiltered?: () => Promise exportMarks?: () => Promise exportLoot?: () => Promise exportViolations?: () => Promise + exportAdherence?: () => Promise exportTimelineSlice?: (from: number, to: number) => Promise revealPath?: (target: string) => Promise } + hooks: { + detect: () => Promise + install: (hookId: string) => Promise<{ success: boolean; message: string }> + uninstall: (hookId: string) => Promise<{ success: boolean; message: string }> + } + plugins: { + list: () => Promise + eventTypes: () => Promise + reload: () => Promise + openFolder: () => Promise + setEnabled: (id: string, enabled: boolean) => Promise + grant: (id: string) => Promise + revoke: (id: string) => Promise + } recording: { get: () => Promise toggle: () => Promise @@ -188,6 +249,11 @@ interface RedLogAPI { onVisibilityChanged: (cb: (visible: boolean) => void) => () => void setExpanded?: (expanded: boolean) => void moveToCorner?: (corner: 'tl' | 'tr' | 'bl' | 'br') => void + // The HUD window is bridged by a separate preload (src/preload/overlay.ts) + // that shares this `window.redlog` type. These two are HUD-only: the main + // window never exposes them, hence optional. + quickMark?: () => void + instantMark?: () => Promise<{ ok: boolean; id?: string }> } operators: { list: () => Promise @@ -210,6 +276,36 @@ interface RedLogAPI { } } +interface HookInfo { + id: string + name: string + description: string + agentType: string + installed: boolean + available: boolean + installMethod: 'claude-settings' | 'shell-source' | 'manual' + hookFile: string + manualSteps?: Array<{ label: string; command?: string }> +} + +interface PluginInfo { + id: string + name: string + version?: string + enabled: boolean + trust?: string + granted?: boolean + eventTypes?: PluginEventType[] + error?: string +} + +interface PluginEventType { + agentType: string + lane?: string + color?: string + label?: string +} + interface CaptureSourceInfo { id: string installed?: boolean @@ -230,7 +326,9 @@ interface CaptureHealthInfo { lastEventAt: number | null checkedAt: number lastDbError?: { source: string; at: number; message: string } - lastSampleBroken?: { at: number; eventId: string; reason: string } + // v0.7.6 H3: `eventTimestamp` carries the broken row's own creation time so + // the Dashboard can label a stale historical row apart from a fresh one. + lastSampleBroken?: { at: number; eventId: string; reason: string; eventTimestamp?: number } lastSampleOkAt?: number | null } @@ -294,12 +392,12 @@ interface RedLogConfigPartial { engagement?: { id?: string; name?: string } operator?: { id?: string; name?: string } network?: { whitelist?: string[]; blacklist?: string[]; safeIPs?: string[]; exposedIPs?: string[]; checkInterval?: number; ipMode?: 'dns' | 'http' | 'auto' } - scope?: { warnOnViolation?: boolean; targets?: string[]; excludeTargets?: string[]; scopeFile?: string | null } - screenshot?: { quality?: number } + // `enforcement` is a legacy field kept for importing pre-migration profiles + // (config.ts migrates 'warn'|'log' → warnOnViolation:boolean on load). + scope?: { alertFloor?: 'excluded_only' | 'adjacent' | 'all'; targets?: string[]; excludeTargets?: string[]; scopeFile?: string | null; warnOnViolation?: boolean; enforcement?: 'warn' | 'log' } + screenshot?: { quality?: number; intervalSec?: number } } -declare global { - interface Window { - redlog: RedLogAPI - } +interface Window { + redlog: RedLogAPI } diff --git a/src/renderer/src/i18n/I18nContext.tsx b/src/renderer/src/i18n/I18nContext.tsx index d2b8e986..1822d59a 100644 --- a/src/renderer/src/i18n/I18nContext.tsx +++ b/src/renderer/src/i18n/I18nContext.tsx @@ -17,7 +17,7 @@ function detectLocale(): Locale { return 'en' } -function interpolate(template: string, vars?: Record): string { +function interpolate(template: string, vars?: Record): string { if (!vars) return template return template.replace(/\{\{(\w+)\}\}/g, (_, key) => String(vars[key] ?? `{{${key}}}`)) } @@ -25,7 +25,7 @@ function interpolate(template: string, vars?: Record): interface I18nContextValue { locale: Locale setLocale: (l: Locale) => void - t: (key: string, vars?: Record) => string + t: (key: string, vars?: Record) => string } const I18nContext = createContext(null!) @@ -38,7 +38,7 @@ export function I18nProvider({ children }: { children: ReactNode }): JSX.Element localStorage.setItem('redlog-locale', l) }, []) - const t = useCallback((key: string, vars?: Record): string => { + const t = useCallback((key: string, vars?: Record): string => { const str = locales[locale]?.[key] ?? locales['en'][key] ?? key return interpolate(str, vars) }, [locale]) diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index 6f4739f3..e668a387 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -2,6 +2,7 @@ "app.title": "REDLOG", "app.subtitle": "Red Team Operation Log", "app.mark": "+ Mark", + "app.search": "Search (⌘/)", "app.copyVersionHint": "Double-click to select for copying", "project.new": "New Project", "project.create": "Create", @@ -19,6 +20,8 @@ "time.dAgo": "{{d}}d ago", "time.sAgo": "{{s}}s ago", "sidebar.dashboard": "Dashboard", + "sidebar.collapse": "Collapse sidebar (⌘B)", + "sidebar.expand": "Expand sidebar (⌘B)", "sidebar.terminal": "Terminal", "sidebar.search": "Search (⌘/)", "sidebar.ops": "OPS", @@ -111,6 +114,13 @@ "timeline.process": "Process", "timeline.cleanup": "Cleanup", "timeline.laneEmpty": "{{lane}} — no events yet", + "timeline.untargeted": "Untargeted", + "timeline.axisSource": "By source", + "timeline.axisTarget": "By target", + "timeline.axisHint": "Group lanes by source type (recording view) or by target (reconstruction view)", + "timeline.phaseRibbon": "Phases", + "timeline.phaseRibbonHint": "Show the phase ribbon — operator phase markers (solid) and inferred suggestions (dashed)", + "timeline.phasePromote": "Inferred phase: {{phase}} — click to add a marker", "timeline.shellEvent": "Shell event", "timeline.scanner": "Scanner", "timeline.laneExternalOnly": "{lane} — no built-in producer; populated only by external agents posting via /api/events", @@ -126,12 +136,12 @@ "settings.integrityVerifying": "Verifying…", "settings.integrityFullOk": "Chain intact", "settings.integrityFullBroken": "Chain broken", - "settings.integrityFullWalked": "Walked {n} events", + "settings.integrityFullWalked": "Walked {{n}} events", "settings.integrityFullBrokenAt": "Broken at event", "settings.integrityFullReason": "Reason", "settings.integrityFullAnchorMatch": "✓ Latest anchor matches walked head", "settings.integrityFullAnchorMismatch": "⚠ Latest anchor does NOT match walked head", - "settings.integrityFullClockAnomalies": "{n} clock anomaly / anomalies detected", + "settings.integrityFullClockAnomalies": "{{n}} clock anomaly / anomalies detected", "timeline.clusterMoreItems": "+{count} more — zoom in to see individually", "timeline.resizeDetailPanel": "Drag to resize the detail panel · double-click to reset", "targets.title": "Targets ({{count}})", @@ -166,6 +176,12 @@ "loot.target": "target", "loot.openInTimeline": "View in timeline", "loot.openInTimelineHint": "Jump to this discovery on the timeline to see the surrounding activity.", + "loot.selectHint": "Show the full value in the detail pane", + "loot.selectPrompt": "Select a loot event to see the full value", + "loot.copy": "Copy", + "loot.copyHint": "Copy the full value to the clipboard", + "loot.copied": "Value copied", + "loot.copyFailed": "Copy failed", "marks.title": "Marks ({{count}})", "marks.capture": "+ Capture", "marks.export": "Export", @@ -304,6 +320,14 @@ "settings.uiScale.xlarge": "Extra large", "settings.hud": "HUD", "settings.capture": "Capture", + "settings.evidence": "Evidence", + "settings.opsec": "OPSEC", + "settings.advanced": "Advanced", + "settings.advanced.integrations": "Integrations", + "settings.advanced.team": "Team", + "settings.advanced.plugins": "Plugins", + "settings.advanced.cloud": "Cloud share", + "settings.advanced.app": "App", "settings.integrations": "Integrations", "settings.team": "Team & Integrations", "settings.networkIp": "Network / IP", @@ -318,6 +342,8 @@ "settings.agents.selfExclusionHint": "A `.redlog-app-root` marker file in a project cwd opts that repo out of tailing — used to prevent RedLog watching its own dev sessions. Commit the marker to any repo you don't want tailed.", "settings.loading": "Loading...", "settings.saved": "Config saved to this project", + "settings.filterPlaceholder": "Filter settings…", + "settings.filterNoResults": "No settings match \"{query}\"", "settings.engagement": "Engagement", "settings.id": "ID", "settings.name": "Name", @@ -412,7 +438,9 @@ "toast.bundleExported": "Bundle exported", "settings.scopeEnforcement": "Scope Warnings", "settings.warnOnViolation": "Warn on out-of-scope targets", - "settings.warnOnViolationHint": "When a command's target is outside scope but shares a root domain with a scope target, raise a red badge + scope_violation event (also fires the deconfliction webhook). Excluded targets always warn regardless. RedLog only records — it never blocks a command.", + "settings.warnOnViolationHint": "Raise a red badge + scope_violation event when a command's target is out of scope but adjacent to one — same subnet as a single-IP scope entry, or same registrable domain (also fires the deconfliction webhook). Explicitly excluded targets always warn regardless of this setting; wholly unrelated targets never do, they are only counted. RedLog only records — it never blocks a command.", + "settings.proximityBits": "Adjacent zone width (CIDR bits)", + "settings.proximityBitsHint": "How far around a single-IP scope entry still counts as adjacent. 24 (default) makes 192.168.1.10 warn for anything in 192.168.1.0/24; 16 widens it to 192.168.0.0/16. Entries already written as CIDRs are never widened — a stated boundary is taken as stated. Range 1–32.", "settings.inScopeTargets": "In-Scope Targets", "settings.targetsLabel": "Targets (IPs, CIDRs, *.domain)", "settings.targetsPlaceholder": "e.g. 192.168.1.0/24 or *.example.com", @@ -507,6 +535,8 @@ "toast.copiedMasked": "Copied (masked view — click Reveal first then Copy for raw bytes)", "terminal.shell": "Shell", "terminal.newTab": "New Terminal", + "terminal.split": "Split pane (side by side)", + "terminal.closePane": "Close this pane", "timeline.zoomIn": "Zoom in (Ctrl+scroll)", "timeline.minimapHint": "Drag to zoom to a time window · click to jump", "timeline.zoomOut": "Zoom out (Ctrl+scroll)", @@ -526,6 +556,8 @@ "timeline.replay.speed": "Speed", "timeline.replay.seek": "Seek", "timeline.replay.truncated": "(cast truncated — file exceeded read cap)", + "timeline.replay.showFull": "Show full output ({{size}})", + "timeline.replay.showLess": "Show less", "timeline.showAll": "Show all", "timeline.showAllLanes": "Show all lanes", "timeline.laneChipHint": "Click to toggle {{lane}}; Alt/Option-click = solo this lane", @@ -584,6 +616,34 @@ "timeline.focusChain.badge": "🔗 Focus chain ({{count}} events)", "timeline.focusChain.exit": "Exit focus mode (Esc)", "timeline.focusChain.enterHint": "Press f to focus this chain", + "timeline.wheelHint.scrolling": "⇅ scrolling lanes", + "timeline.wheelHint.panWith": "⇧-scroll to pan", + "timeline.mode.active": "Active:", + "timeline.mode.clear": "Clear this mode", + "timeline.mode.clearAll": "clear all", + "timeline.mode.focus": "◎ focus chain", + "timeline.mode.anomaly": "⚑ anomalies only", + "timeline.mode.collapseAgent": "⇘ agent turns hidden", + "timeline.mode.solo": "solo: {{value}}", + "timeline.mode.hiddenLanes": "{{value}} lanes hidden", + "timeline.mode.filter": "/ \"{{value}}\"", + "timeline.legend.pan": "⇔ drag to pan", + "timeline.legend.zoom": "{{mod}}-scroll to zoom", + "timeline.legend.filter": "/ filter", + "timeline.legend.shortcuts": "? all shortcuts", + "timeline.legend.collapse": "Collapse legend", + "timeline.legend.expand": "Show interaction legend", + "shortcut.switchView": "Switch view", + "shortcut.quickMarker": "Drop a quick marker", + "shortcut.search": "Search events", + "shortcut.pauseRecording": "Pause / resume recording", + "shortcut.filterEvents": "Focus inline filter", + "shortcut.toggleHelp": "Show this shortcut menu", + "shortcut.focusChain": "Toggle focus-chain mode", + "shortcut.commandPalette": "Fuzzy command palette", + "shortcut.navigateEvents": "Walk selection up / down", + "shortcut.soloLane": "Solo a lane (hide the others)", + "shortcut.dropMarker": "Drop a marker at this timestamp", "timeline.anomalies.chip": "⚠ Anomalies ({{count}})", "timeline.anomalies.tooltip": "Dim every event without a chain-integrity flag (clock anomaly, orphan recovery, evidence removal, anchor failure, chain break)", "timeline.brokenChain.banner": "⛓️‍💥 Chain broken at event {{brokenAtId}}… (walked {{walked}}). All events after are unverified.", @@ -622,6 +682,10 @@ "overlay.rec": "REC", "overlay.paused": "PAUSED", "screenshots.emptyDesc": "Screenshots are captured automatically on idle or manually via the capture button.", + "empty.action.setupCapture": "Set up capture", + "empty.action.captureNow": "Capture now", + "empty.action.mark": "Add a marker (⌘⇧M)", + "empty.action.learnMore": "Learn more", "project.advancedSetup": "Advanced Setup (optional)", "project.advancedSummary": "scope {scope} / safe {safe} / exposed {exposed}", "project.done": "Done", @@ -760,7 +824,7 @@ "settings.scopeExport": "Scope-Filtered Export", "settings.exportScopeJson": "Export Scope-Only JSON", "settings.scopeExportHint": "Exports only events matching configured scope targets. Use this when uploading logs to a remote server — out-of-scope data is excluded.", - "timeline.detail.ioOnDisk": "output recorded — {{size}} of session capture, expand below to read it", + "timeline.detail.ioOnDisk": "output recorded — {{size}} of session capture, shown below", "timeline.detail.ioNone": "nothing recorded for this command", "timeline.detail.ioUnbracketed": "output not bracketed — RedLog could not match this command to a span of the session recording. Try the full session replay below.", "timeline.detail.ioNotCaptured": "output not captured — external shells record the command only. Prefix with `redlog-run` to capture stdout/stderr, or use the built-in terminal.", @@ -804,9 +868,16 @@ "marketplace.publisherTrusted": "Trusted {{id}} — its signed plugins can now be installed", "timeline.detail.httpRequestParams": "request", "timeline.detail.httpResponseBody": "response body", + "timeline.detail.httpRequestBody": "request body", "timeline.detail.httpHeaders": "headers", "timeline.detail.httpBodyNotCaptured": "response body not captured — {{size}} of {{type}}. mitmproxy stores a 2 KB preview of textual responses only.", "timeline.detail.httpBodyBinary": "response body not captured — {{size}} of {{type}}, which is not a textual type.", + "timeline.detail.ioLoadFull": "Load full body ({{size}})", + "timeline.detail.ioLoading": "Loading…", + "timeline.detail.ioTooLarge": "Body exceeds the {{max}} inline limit — export the bundle to read it in full.", + "timeline.detail.ioUnavailable": "Full body unavailable — pruned by retention or not on disk.", + "timeline.detail.ioCeiling": "capture-capped", + "timeline.detail.ioCeilingHint": "The body was larger than the capture ceiling (REDLOG_MAX_IO); the stored copy is truncated.", "timeline.detail.consoleMessage": "message", "timeline.detail.consoleStack": "stack", "sidebar.transcript": "Transcript", @@ -816,6 +887,13 @@ "transcript.exportMd": "Copy as Markdown", "transcript.copied": "Transcript copied as Markdown", "transcript.copyFailed": "Copy failed", + "transcript.selectHint": "Show this exchange in the detail pane", + "transcript.selectPrompt": "Select an exchange to read it in full", + "transcript.input": "Input", + "transcript.output": "Output", + "transcript.copy": "Copy", + "transcript.copyHint": "Copy to the clipboard", + "transcript.valueCopied": "Copied", "transcript.loading": "Loading…", "transcript.empty": "Nothing captured yet, or everything is filtered out.", "transcript.openInTimeline": "Show on the Timeline", @@ -833,5 +911,53 @@ "timeline.shape.scopeViolation": "out of scope", "timeline.compressGaps.label": "skip idle", "timeline.compressGaps.hint": "Collapse stretches longer than 10 minutes with no events, so dense work gets the width instead of the quiet. The axis stops being proportional while this is on — each collapsed stretch is drawn as a hatched break with its duration.", - "timeline.gapSkipped": "{{d}} with no events, collapsed" + "timeline.gapSkipped": "{{d}} with no events, collapsed", + "statusBar.ipStale": "NO READ", + "statusBar.staleHint": "No successful IP read for {{n}} checks — the last verdict has expired", + "statusBar.settlingHint": "A new address is being confirmed — showing the last stable read", + "ip.staleIp": "No current reading", + "ip.staleHint": "The external IP could not be read for {{n}} consecutive checks, so the safety verdict has expired. The address shown is the last one seen — a dropped VPN or kill-switch looks exactly like this.", + "ip.settlingHint": "A new external address is being confirmed. Showing the last stable read until it repeats.", + "overlay.ipStale": "NO READ", + "overlay.staleIpStatus": "No current reading — verdict expired", + "settings.deconflictionFactOnly": "Forward observed rule matches only", + "settings.deconflictionFactOnlyHint": "Holds back proximity inferences (same subnet / same domain). Every forwarded event is labelled with its authority either way, so leaving this off tells the blue team more, not less accurately.", + "timeline.authority.inferred": "inferred — a suggestion, not a recorded fact", + "statusBar.offProfileIp": "OFF-PROFILE", + "statusBar.offProfileHint": "Your external IP is not on the Safe IP list — you are not exiting where you said you would", + "statusBar.presumedHint": "Not on your Exposed IP list, but no Safe IP list to confirm against — this is an inference, not a verified exit", + "ip.offProfileIp": "Off-profile IP", + "ip.offProfileHint": "A Safe IP list is configured and this address is not on it. Your egress is not where you declared it would be — a dropped tunnel, a switched exit node, or a pool you did not list.", + "ip.presumedSafeIp": "Presumed safe", + "ip.presumedHint": "This address is not on your Exposed IP list, so it is probably not you — but with no Safe IP list there is nothing to confirm it against. Add your attack exits under Settings ▸ Network to turn this into a verified result.", + "ip.listConflictHint": "This address matches BOTH your Safe IP and Exposed IP lists. The Exposed list wins, so the verdict above stands — but one of the two entries is wrong and should be fixed.", + "overlay.offProfileIp": "OFF-PROFILE", + "overlay.offProfileIpStatus": "Off-profile — not a declared exit", + "overlay.presumedSafeIp": "PRESUMED", + "overlay.presumedSafeIpStatus": "Presumed safe — inferred, not verified", + "scope.reason.excluded_target": "forbidden target", + "scope.reason.adjacent_subnet": "same subnet", + "scope.reason.adjacent_domain": "same domain", + "scope.reason.unrelated": "off-list", + "settings.alertFloor.excluded_only": "Forbidden only", + "settings.alertFloor.adjacent": "+ Near-misses", + "settings.alertFloor.all": "Everything off-list", + "settings.alertFloorHint.excluded_only": "Alerts only on targets you explicitly excluded. Use during recon, when third-party services dominate the target stream.", + "settings.alertFloorHint.adjacent": "Also alerts when a target is out of scope but sits next to one that is in scope — same subnet as a listed host, or same registrable domain. Right subnet, wrong box is the mistake this catches.", + "settings.alertFloorHint.all": "Also records every unrelated target. Strict authorisation: anything not on the list goes on the record. Expect noise if you use public services during the engagement.", + "settings.alertFloorD1Note": "Explicitly excluded targets alert at every setting — telling RedLog to keep off a host is not a preference.", + "dashboard.alertFloor.excluded_only": "forbidden targets only", + "dashboard.alertFloor.adjacent": "warnings on", + "dashboard.alertFloor.all": "strict — everything off-list", + "scope.exportAdherence": "Export adherence", + "scope.exportAdherenceHint": "Every target touched and how each classifies — the proof you stayed in scope, not just the list of times you did not", + "scope.adherenceHint": "across {{actions}} recorded action(s), classified against the current scope", + "scope.adherenceScopeChanged": "Scope was edited {{n}} time(s) during this engagement — these counts judge the whole window by the CURRENT list", + "scope.provenance": "scope from file: {{entries}} entries, sha256 {{digest}}…", + "scope.provenanceEmpty": "The scope file at {{path}} parsed to zero entries — it is contributing no targets", + "scope.provenanceUnreadable": "The scope file at {{path}} could not be read — it is contributing no targets", + "ip.lanOffProfileHint": "Your internal address is not on any of the LAN segments this engagement expects. A silent reassociation to a guest SSID looks exactly like this — the external IP can still be fine while you are on the wrong network.", + "settings.lanProfile": "Expected LAN segments", + "settings.lanProfileHint": "CIDRs you expect to be on internally this engagement (e.g. the client VLAN). Leave empty to skip the check. The internal address is judged with the same rules as the Safe IP list.", + "settings.lanProfilePlaceholder": "e.g. 10.10.20.0/24" } diff --git a/src/renderer/src/i18n/zh-TW.json b/src/renderer/src/i18n/zh-TW.json index e01c7be2..80e8e83d 100644 --- a/src/renderer/src/i18n/zh-TW.json +++ b/src/renderer/src/i18n/zh-TW.json @@ -2,6 +2,7 @@ "app.title": "REDLOG", "app.subtitle": "紅隊行動日誌", "app.mark": "+ 標記", + "app.search": "搜尋 (⌘/)", "app.copyVersionHint": "點兩下選取版號複製", "project.new": "新專案", "project.create": "建立", @@ -19,6 +20,8 @@ "time.dAgo": "{{d}} 天前", "time.sAgo": "{{s}} 秒前", "sidebar.dashboard": "儀表板", + "sidebar.collapse": "收合側邊欄 (⌘B)", + "sidebar.expand": "展開側邊欄 (⌘B)", "sidebar.terminal": "終端機", "sidebar.search": "搜尋 (⌘/)", "sidebar.ops": "作戰", @@ -111,6 +114,13 @@ "timeline.process": "程序", "timeline.cleanup": "清理痕跡", "timeline.laneEmpty": "{{lane}} — 尚無事件", + "timeline.untargeted": "無目標", + "timeline.axisSource": "依來源", + "timeline.axisTarget": "依目標", + "timeline.axisHint": "lane 依來源類型分(記錄視圖)或依目標分(重建視圖)", + "timeline.phaseRibbon": "階段", + "timeline.phaseRibbonHint": "顯示階段帶 — operator 階段標記(實線)與推論建議(虛線)", + "timeline.phasePromote": "推論階段:{{phase}} — 點擊新增標記", "timeline.shellEvent": "Shell 事件", "timeline.scanner": "掃描", "timeline.laneExternalOnly": "{lane} — 沒有內建擷取來源;只有外部 agent 透過 /api/events 送才會出現", @@ -126,12 +136,12 @@ "settings.integrityVerifying": "驗證中⋯", "settings.integrityFullOk": "證據鏈完整", "settings.integrityFullBroken": "證據鏈損壞", - "settings.integrityFullWalked": "已走過 {n} 筆事件", + "settings.integrityFullWalked": "已走過 {{n}} 筆事件", "settings.integrityFullBrokenAt": "斷點事件", "settings.integrityFullReason": "原因", "settings.integrityFullAnchorMatch": "✓ 最新錨點對應走完後的 head", "settings.integrityFullAnchorMismatch": "⚠ 最新錨點與走完後的 head 不符", - "settings.integrityFullClockAnomalies": "偵測到 {n} 次時鐘異常", + "settings.integrityFullClockAnomalies": "偵測到 {{n}} 次時鐘異常", "timeline.clusterMoreItems": "還有 {count} 個 — 放大以個別檢視", "timeline.resizeDetailPanel": "拖曳調整詳細面板高度 · 雙擊還原預設", "targets.title": "目標({{count}})", @@ -166,6 +176,12 @@ "loot.target": "目標", "loot.openInTimeline": "在時間軸檢視", "loot.openInTimelineHint": "跳到時間軸上這筆發現,查看前後的相關活動。", + "loot.selectHint": "在詳情面板顯示完整值", + "loot.selectPrompt": "選一筆戰利品以檢視完整值", + "loot.copy": "複製", + "loot.copyHint": "複製完整值到剪貼簿", + "loot.copied": "已複製", + "loot.copyFailed": "複製失敗", "marks.title": "標記({{count}})", "marks.capture": "+ 擷取", "marks.export": "匯出", @@ -304,6 +320,14 @@ "settings.uiScale.xlarge": "特大", "settings.hud": "HUD", "settings.capture": "擷取", + "settings.evidence": "證據", + "settings.opsec": "OPSEC", + "settings.advanced": "進階", + "settings.advanced.integrations": "整合", + "settings.advanced.team": "團隊", + "settings.advanced.plugins": "外掛", + "settings.advanced.cloud": "雲端分享", + "settings.advanced.app": "應用程式", "settings.integrations": "整合", "settings.team": "團隊與整合", "settings.networkIp": "網路 / IP", @@ -318,6 +342,8 @@ "settings.agents.selfExclusionHint": "cwd 裡放 `.redlog-app-root` 標記檔可以把該 repo 排除在 tailing 之外 — 用來避免 RedLog 監聽自己的開發 session。任何你不想被記的 repo 都可以 commit 這個檔案。", "settings.loading": "載入中...", "settings.saved": "設定已儲存至此專案", + "settings.filterPlaceholder": "過濾設定…", + "settings.filterNoResults": "沒有符合「{query}」的設定", "settings.engagement": "任務資訊", "settings.id": "ID", "settings.name": "名稱", @@ -412,7 +438,9 @@ "toast.bundleExported": "已匯出證據包", "settings.scopeEnforcement": "範圍警告", "settings.warnOnViolation": "碰到範圍外目標時示警", - "settings.warnOnViolationHint": "當指令目標超出 scope 且與 scope 目標同 root domain,發出紅色 badge + scope_violation 事件(通知藍隊 deconfliction)。明確排除的目標無論此設定為何都會示警。RedLog 只記錄,絕不會擋指令。", + "settings.warnOnViolationHint": "當指令目標超出 scope 但緊鄰 scope(與單一 IP 項目同網段,或同一個可註冊網域)時,發出紅色 badge + scope_violation 事件(通知藍隊 deconfliction)。明確排除的目標無論此設定為何都會示警;完全無關的目標則永遠不示警,只會被計數。RedLog 只記錄,絕不會擋指令。", + "settings.proximityBits": "鄰近區範圍(CIDR 位元數)", + "settings.proximityBitsHint": "單一 IP 的 scope 項目往外多遠仍算「鄰近」。24(預設)讓 192.168.1.10 對 192.168.1.0/24 內的任何位址示警;16 則放寬到 192.168.0.0/16。已寫成 CIDR 的項目永遠不會被放寬——已聲明的邊界就照聲明處理。範圍 1–32。", "settings.inScopeTargets": "範圍內目標", "settings.targetsLabel": "目標(IP、CIDR、*.domain)", "settings.targetsPlaceholder": "例如 192.168.1.0/24 或 *.example.com", @@ -507,6 +535,8 @@ "toast.copiedMasked": "已複製(遮蔽後版本 — 點「顯示機密」後再複製可得原始位元組)", "terminal.shell": "終端", "terminal.newTab": "新增終端", + "terminal.split": "分割窗格(並排)", + "terminal.closePane": "關閉此窗格", "timeline.zoomIn": "放大(Ctrl+滾輪)", "timeline.minimapHint": "拖曳選取時間範圍以放大 · 點擊跳至該時間", "timeline.zoomOut": "縮小(Ctrl+滾輪)", @@ -526,6 +556,8 @@ "timeline.replay.speed": "速度", "timeline.replay.seek": "跳到", "timeline.replay.truncated": "(cast 已截斷 — 檔案超過讀取上限)", + "timeline.replay.showFull": "顯示完整輸出({{size}})", + "timeline.replay.showLess": "收合", "timeline.showAll": "全部顯示", "timeline.showAllLanes": "顯示所有欄位", "timeline.laneChipHint": "點擊切換{{lane}}顯示;按住 Alt/Option 點擊 = 只看該欄", @@ -584,6 +616,34 @@ "timeline.focusChain.badge": "🔗 聚焦鏈({{count}} 個事件)", "timeline.focusChain.exit": "退出聚焦模式(Esc)", "timeline.focusChain.enterHint": "按 f 聚焦這條鏈", + "timeline.wheelHint.scrolling": "⇅ 捲動 lane", + "timeline.wheelHint.panWith": "⇧+滾輪平移", + "timeline.mode.active": "生效中:", + "timeline.mode.clear": "清除此模式", + "timeline.mode.clearAll": "全部清除", + "timeline.mode.focus": "◎ 聚焦鏈", + "timeline.mode.anomaly": "⚑ 僅異常", + "timeline.mode.collapseAgent": "⇘ 已摺疊 agent 回合", + "timeline.mode.solo": "單獨:{{value}}", + "timeline.mode.hiddenLanes": "已隱藏 {{value}} 個 lane", + "timeline.mode.filter": "/ \"{{value}}\"", + "timeline.legend.pan": "⇔ 拖曳平移", + "timeline.legend.zoom": "{{mod}}+滾輪縮放", + "timeline.legend.filter": "/ 篩選", + "timeline.legend.shortcuts": "? 所有快捷鍵", + "timeline.legend.collapse": "收合圖例", + "timeline.legend.expand": "顯示互動圖例", + "shortcut.switchView": "切換檢視", + "shortcut.quickMarker": "快速標記", + "shortcut.search": "搜尋事件", + "shortcut.pauseRecording": "暫停/恢復錄製", + "shortcut.filterEvents": "聚焦行內篩選", + "shortcut.toggleHelp": "顯示此快捷鍵選單", + "shortcut.focusChain": "切換聚焦鏈模式", + "shortcut.commandPalette": "模糊命令面板", + "shortcut.navigateEvents": "上/下移動選取", + "shortcut.soloLane": "單獨顯示一個 lane(隱藏其他)", + "shortcut.dropMarker": "在此時間戳放置標記", "timeline.anomalies.chip": "⚠ 異常({{count}})", "timeline.anomalies.tooltip": "把沒有整合旗標的事件變暗(時鐘異常、孤兒回收、證據刪除、錨定失敗、鏈斷)", "timeline.brokenChain.banner": "⛓️‍💥 證據鏈在事件 {{brokenAtId}}… 之後斷開(走完 {{walked}} 個)。之後的所有事件皆未驗證。", @@ -622,6 +682,10 @@ "overlay.rec": "錄製", "overlay.paused": "已暫停", "screenshots.emptyDesc": "截圖會在閒置時自動擷取,或透過擷取按鈕手動觸發。", + "empty.action.setupCapture": "設定擷取", + "empty.action.captureNow": "立即擷取", + "empty.action.mark": "新增標記 (⌘⇧M)", + "empty.action.learnMore": "了解更多", "project.advancedSetup": "進階設定(選填)", "project.advancedSummary": "範圍 {scope}/白 {safe}/黑 {exposed}", "project.done": "完成", @@ -760,7 +824,7 @@ "settings.scopeExport": "範圍過濾匯出", "settings.exportScopeJson": "匯出範圍內 JSON", "settings.scopeExportHint": "僅匯出符合已設定範圍目標的事件。適用於上傳日誌至遠端伺服器時——排除範圍外資料。", - "timeline.detail.ioOnDisk": "已記錄輸出 — 工作階段錄影 {{size}},展開下方即可閱讀", + "timeline.detail.ioOnDisk": "已記錄輸出 — 工作階段錄影 {{size}},顯示於下方", "timeline.detail.ioNone": "這條指令沒有留下任何錄影段落", "timeline.detail.ioUnbracketed": "輸出無法定位 — RedLog 無法將這條指令對應到工作階段錄影的某一段。請改用下方的完整工作階段重播。", "timeline.detail.ioNotCaptured": "未捕獲輸出 — 外部 shell 只記錄指令本身。在指令前加上 `redlog-run` 可捕獲 stdout/stderr,或改用內建終端機。", @@ -804,9 +868,16 @@ "marketplace.publisherTrusted": "已信任 {{id}} — 其簽章的 plugin 現在可以安裝", "timeline.detail.httpRequestParams": "請求內容", "timeline.detail.httpResponseBody": "回應主體", + "timeline.detail.httpRequestBody": "請求主體", "timeline.detail.httpHeaders": "標頭", "timeline.detail.httpBodyNotCaptured": "未捕獲回應主體 — {{size}} 的 {{type}}。mitmproxy 僅儲存文字型回應的 2 KB 預覽。", "timeline.detail.httpBodyBinary": "未捕獲回應主體 — {{size}} 的 {{type}},並非文字型內容。", + "timeline.detail.ioLoadFull": "載入完整主體({{size}})", + "timeline.detail.ioLoading": "載入中…", + "timeline.detail.ioTooLarge": "主體超過 {{max}} 的行內上限 — 請匯出封存包以完整檢視。", + "timeline.detail.ioUnavailable": "無法取得完整主體 — 已被保留策略清除或不在磁碟上。", + "timeline.detail.ioCeiling": "捕獲已截斷", + "timeline.detail.ioCeilingHint": "主體大於捕獲上限(REDLOG_MAX_IO);磁碟上儲存的副本為截斷版。", "timeline.detail.consoleMessage": "訊息", "timeline.detail.consoleStack": "堆疊", "sidebar.transcript": "逐字稿", @@ -816,6 +887,13 @@ "transcript.exportMd": "複製為 Markdown", "transcript.copied": "逐字稿已複製為 Markdown", "transcript.copyFailed": "複製失敗", + "transcript.selectHint": "在詳情面板顯示這筆交換", + "transcript.selectPrompt": "選一筆交換以檢視完整內容", + "transcript.input": "輸入", + "transcript.output": "輸出", + "transcript.copy": "複製", + "transcript.copyHint": "複製到剪貼簿", + "transcript.valueCopied": "已複製", "transcript.loading": "載入中…", "transcript.empty": "尚未捕獲任何內容,或全部被篩選掉了。", "transcript.openInTimeline": "在時間軸上顯示", @@ -833,5 +911,53 @@ "timeline.shape.scopeViolation": "超出範圍", "timeline.compressGaps.label": "略過閒置", "timeline.compressGaps.hint": "收合超過 10 分鐘且沒有事件的時段,把寬度讓給密集的工作。開啟時軸不再等比例 — 每個被收合的時段會畫成帶時長的斜紋斷裂。", - "timeline.gapSkipped": "{{d}} 沒有事件,已收合" + "timeline.gapSkipped": "{{d}} 沒有事件,已收合", + "statusBar.ipStale": "無讀數", + "statusBar.staleHint": "已連續 {{n}} 次讀不到 IP —— 上一次判定已過期", + "statusBar.settlingHint": "正在確認新位址 —— 顯示的是上一個穩定讀數", + "ip.staleIp": "無當前讀數", + "ip.staleHint": "已連續 {{n}} 次讀不到對外 IP,安全判定已過期。畫面上的位址是最後一次看到的 —— VPN 掉線或 kill switch 斷網看起來就是這樣。", + "ip.settlingHint": "正在確認新的對外位址。在它重複出現前,顯示上一個穩定讀數。", + "overlay.ipStale": "無讀數", + "overlay.staleIpStatus": "無當前讀數 —— 判定已過期", + "settings.deconflictionFactOnly": "只轉發觀察到的規則命中", + "settings.deconflictionFactOnlyHint": "保留鄰近推論(同網段/同 domain)不外送。無論開關與否,每筆轉發的事件都會標註其權威等級——關閉此項是讓藍隊收到更多,而非更不準確。", + "timeline.authority.inferred": "推論——這是建議,不是已記錄的事實", + "statusBar.offProfileIp": "偏離", + "statusBar.offProfileHint": "對外 IP 不在安全 IP 清單上——你不在你宣告的出口", + "statusBar.presumedHint": "不在暴露 IP 清單上,但沒有安全 IP 清單可對照——這是推論,不是已驗證的出口", + "ip.offProfileIp": "偏離的 IP", + "ip.offProfileHint": "已設定安全 IP 清單,而這個位址不在上面。你的出口不在你宣告的位置——可能是通道掉線、出口節點切換,或是一個你沒列進去的位址池。", + "ip.presumedSafeIp": "推定安全", + "ip.presumedHint": "這個位址不在你的暴露 IP 清單上,所以大概不是你本人——但沒有安全 IP 清單就沒有東西能確認它。在「設定 ▸ 網路」加入你的攻擊出口,可以讓這個判定變成已驗證的結果。", + "ip.listConflictHint": "這個位址同時命中安全 IP 與暴露 IP 兩份清單。暴露清單優先,所以上面的判定成立——但兩筆設定裡有一筆是錯的,應該修正。", + "overlay.offProfileIp": "偏離", + "overlay.offProfileIpStatus": "偏離——不是宣告過的出口", + "overlay.presumedSafeIp": "推定", + "overlay.presumedSafeIpStatus": "推定安全——推論,未驗證", + "scope.reason.excluded_target": "禁止的目標", + "scope.reason.adjacent_subnet": "同網段", + "scope.reason.adjacent_domain": "同 domain", + "scope.reason.unrelated": "清單外", + "settings.alertFloor.excluded_only": "僅禁止目標", + "settings.alertFloor.adjacent": "+ 鄰近誤差", + "settings.alertFloor.all": "所有清單外目標", + "settings.alertFloorHint.excluded_only": "只對你明確排除的目標警報。適合 recon 階段——那時目標流裡大多是第三方服務。", + "settings.alertFloorHint.adjacent": "額外在目標超出範圍、但緊鄰範圍內目標時警報——與清單上主機同網段,或同一個註冊網域。「網段對、機器打錯」正是這一階要抓的失誤。", + "settings.alertFloorHint.all": "額外記錄每一個不相干的目標。嚴格授權:不在清單上的任何東西都要留下紀錄。若你在 engagement 期間會用到公開服務,預期會有噪音。", + "settings.alertFloorD1Note": "明確排除的目標在每一種設定下都會警報——叫 RedLog 別碰某台主機,不是一個偏好。", + "dashboard.alertFloor.excluded_only": "僅禁止目標", + "dashboard.alertFloor.adjacent": "警告開啟", + "dashboard.alertFloor.all": "嚴格——所有清單外目標", + "scope.exportAdherence": "匯出合規證明", + "scope.exportAdherenceHint": "所有碰過的目標與各自的分類——證明你留在範圍內,而不只是列出沒留住的那幾次", + "scope.adherenceHint": "涵蓋 {{actions}} 筆已記錄的動作,依目前的範圍設定分類", + "scope.adherenceScopeChanged": "本次 engagement 期間範圍被修改過 {{n}} 次——這些統計是用「目前」的清單回頭判斷整個區間", + "scope.provenance": "範圍來自檔案:{{entries}} 筆,sha256 {{digest}}…", + "scope.provenanceEmpty": "{{path}} 這份範圍檔解析出 0 筆——它沒有貢獻任何目標", + "scope.provenanceUnreadable": "{{path}} 這份範圍檔讀不到——它沒有貢獻任何目標", + "ip.lanOffProfileHint": "你的內部位址不在這場 engagement 預期的任何一個網段上。無聲地重新連上訪客 SSID 看起來就是這樣——對外 IP 可能完全正常,而你已經在錯的網路上。", + "settings.lanProfile": "預期的內網網段", + "settings.lanProfileHint": "這場 engagement 你預期會待在的內網 CIDR(例如客戶 VLAN)。留空即不檢查。內部位址採用與安全 IP 清單相同的判定規則。", + "settings.lanProfilePlaceholder": "例如 10.10.20.0/24" } diff --git a/src/renderer/src/lib/alertSeverity.ts b/src/renderer/src/lib/alertSeverity.ts new file mode 100644 index 00000000..fef79b28 --- /dev/null +++ b/src/renderer/src/lib/alertSeverity.ts @@ -0,0 +1,82 @@ +// The one severity scale (`ALERT-ROLES.md` G-C1). +// +// The two alarm roles grew their own colour vocabularies independently and they +// did not line up: +// +// * Self alarm — four tones (green / orange / amber / red), one per verdict. +// * Target alarm — an ON/OFF LIGHT. `scopeViolations > 0 ? red : green`, so a +// D1 hit on an explicitly forbidden host and a D2 proximity inference +// rendered identically. G-B4 made them distinguishable in the data and +// G-C2 on the wire; the operator's eye was where the distinction stopped. +// +// SEVERITY AND AUTHORITY ARE ORTHOGONAL — do not fold one into the other: +// +// * severity (here) — how loudly to shout. Sets the COLOUR. +// * authority (K1) — observed or inferred. Sets the FILL: solid, or hollow +// and unglowing. +// +// That is why `presumed_safe` and a D2 near-miss are both inferred yet look +// nothing alike: `ok` + inferred is a hollow green, `warn` + inferred a hollow +// orange. Collapsing the axes would force one of those two to lie. + +import { HUD } from './hud' + +export type Severity = 'ok' | 'notice' | 'unknown' | 'warn' | 'critical' + +/** Ordered worst-first, so `worstSeverity` and any future sort share one order. + * `notice` sits below `unknown`: "here is something that happened, out of scope + * but unrelated" asks less of the operator than "I cannot tell you whether you + * are safe". */ +const RANK: Record = { critical: 4, warn: 3, unknown: 2, notice: 1, ok: 0 } + +export const SEVERITY_HUD: Record = { + ok: HUD.green, + notice: HUD.muted, + unknown: HUD.amber, + warn: HUD.orange, + critical: HUD.red +} + +/** Tailwind classes for the app surfaces. Same four steps as `SEVERITY_HUD` — + * the soften map in `tailwind.config.js` keeps the hexes identical. */ +export const SEVERITY_CLASS: Record = { + ok: { dot: 'bg-emerald-500', text: 'text-emerald-400/80', border: 'border-emerald-500' }, + notice: { dot: 'bg-zinc-500', text: 'text-zinc-400/80', border: 'border-zinc-500' }, + unknown: { dot: 'bg-amber-500', text: 'text-amber-400/80', border: 'border-amber-500' }, + warn: { dot: 'bg-orange-500', text: 'text-orange-400/80', border: 'border-orange-500' }, + critical: { dot: 'bg-red-500', text: 'text-red-400/80', border: 'border-red-500' } +} + +type IPVerdict = 'safe' | 'presumed_safe' | 'off_profile' | 'exposed' | 'unknown' + +/** Self alarm → the scale. `presumed_safe` is `ok`: it is the good answer, + * merely an inferred one, and the inference is carried by authority rather + * than by pretending the situation is worse than it is. */ +export function ipSeverity(verdict: IPVerdict): Severity { + switch (verdict) { + case 'exposed': return 'critical' + case 'off_profile': return 'warn' + case 'unknown': return 'unknown' + default: return 'ok' + } +} + +/** Target alarm → the scale. D1 sits level with `exposed`: both are the thing + * the operator must not do, both observed, both non-silenceable — one is an + * OPSEC failure, the other an authorisation failure. + * + * D3 only reaches here under `alertFloor: 'all'`, and gets its own step rather + * than borrowing D2's. Giving it `warn` would put the noise G-B3 removed back + * inside the violation list; giving it `ok` would render a recorded departure + * green. `notice` is grey: on the record, not shouting. */ +export function scopeSeverity( + reason: 'excluded_target' | 'adjacent_subnet' | 'adjacent_domain' | 'unrelated' +): Severity { + if (reason === 'excluded_target') return 'critical' + if (reason === 'unrelated') return 'notice' + return 'warn' +} + +export function worstSeverity(list: readonly Severity[]): Severity { + return list.reduce((worst, s) => (RANK[s] > RANK[worst] ? s : worst), 'ok') +} diff --git a/src/renderer/src/lib/dotShape.ts b/src/renderer/src/lib/dotShape.ts new file mode 100644 index 00000000..150a7663 --- /dev/null +++ b/src/renderer/src/lib/dotShape.ts @@ -0,0 +1,53 @@ +// Extracted from Timeline.tsx so the §3 solid-vs-dashed rule has test coverage. +// Before K1 the distinction existed only on the phase ribbon, and it was wired +// to which ARRAY a band came from rather than to any authority field — nothing +// could assert that an inferred event dot renders differently from an observed +// one, because nothing decided it in a place a test could reach. +// +// The classification itself is NOT duplicated here. `insertEvent` resolves it +// once in core (`authority.ts`) and stamps `data.authority = 'inferred'` into +// the hashed row; absence means `fact`. The renderer reads that field, because +// it cannot import core (see `lib/mask.ts`) and a second copy of the table on +// this side of the process boundary is the exact drift K1 exists to end. + +export type DotShape = 'circle' | 'diamond' | 'ring' + +export interface DotMarks { + shape: DotShape + scale: number + /** §3: this event records a judgement, not an observation. Renders as a + * dashed outline over an unfilled body, with no glow — the same statement + * the phase ribbon makes with a dashed segment. */ + inferred: boolean +} + +interface EventLike { + agentType: string + data?: Record | null +} + +export function isInferredEvent(e: EventLike): boolean { + return e.data?.authority === 'inferred' +} + +/** + * scope violation diamond out of bounds is categorical + * critical marker ring (hollow) reads as an outline, not a fill + * important marker larger circle + * everything else circle + * + * Shape and authority are orthogonal: a scope violation is a diamond whether it + * is a fact (excluded target) or an inference (proximity), and the stroke says + * which. + */ +export function dotShape(e: EventLike): DotMarks { + const sub = e.data?.subtype as string | undefined + const inferred = isInferredEvent(e) + if (e.agentType === 'system' && sub === 'scope_violation') return { shape: 'diamond', scale: 1.25, inferred } + if (e.agentType === 'marker') { + const sev = String(e.data?.severity ?? 'info') + if (sev === 'critical') return { shape: 'ring', scale: 1.5, inferred } + if (sev === 'important') return { shape: 'circle', scale: 1.25, inferred } + } + return { shape: 'circle', scale: 1, inferred } +} diff --git a/src/renderer/src/lib/emptyState.ts b/src/renderer/src/lib/emptyState.ts new file mode 100644 index 00000000..3c701a62 --- /dev/null +++ b/src/renderer/src/lib/emptyState.ts @@ -0,0 +1,107 @@ +// Empty-state decision seam (UX-AUDIT / UX-BACKLOG F4). +// +// `Feedback.tsx` ships a CTA-carrying `EmptyState` primitive that had zero +// usages — every view rolled its own actionless "nothing here" dead end. This +// pure function is the first half of the fix: it decides, per view and per +// capture context, WHICH copy the empty state shows and WHAT the single call to +// action is. The wiring step (separate) feeds the result into ``. +// +// Two deliberate constraints keep this seam collision-free with the live app: +// 1. titleKey / subtitleKey REUSE i18n keys that already exist in en.json. +// This function never invents an i18n key. +// 2. action.labelKey is a STABLE, non-i18n identifier (e.g. +// `empty.action.setupCapture`). The real label is added to i18n at wiring +// time; keeping it out of here means this file never touches en.json. +// +// `target` is the CTA destination identifier the wiring layer routes on: a nav +// target (`dashboard`, `screenshot`, `marker`) or a doc anchor (`doc`). + +/** The six views that have an empty state worth wiring a CTA into. */ +export type EmptyView = 'timeline' | 'screenshots' | 'targets' | 'loot' | 'marks' | 'transcript' + +export interface EmptyStateModel { + /** existing i18n key for the empty-state title */ + titleKey: string + /** existing i18n key for the empty-state subtitle */ + subtitleKey: string + /** the single call-to-action; absent when there is nothing useful to do yet */ + action?: { labelKey: string; target: string } +} + +/** Capture context that changes what the most useful next action is. */ +export interface EmptyStateCtx { + /** true when no capture source is feeding events — the timeline stays dark. */ + captureDark: boolean +} + +// Stable, non-i18n action identifiers. Resolved to real labels at wiring time. +const SETUP_CAPTURE = { labelKey: 'empty.action.setupCapture', target: 'dashboard' } as const +const CAPTURE_NOW = { labelKey: 'empty.action.captureNow', target: 'screenshot' } as const +const MARK_NOW = { labelKey: 'empty.action.mark', target: 'marker' } as const +const LEARN_MORE = { labelKey: 'empty.action.learnMore', target: 'doc' } as const + +// Per-view copy (reused i18n keys) + the action used when capture IS feeding. +// The capture-dark override is applied afterwards for the views where a dark +// timeline is the real reason they're empty (DESIGN-PRINCIPLES §1: without +// capture, nothing lands anywhere). +const BASE: Record = { + // Timeline light-capture case has no action: events simply haven't arrived yet + // and pushing to Dashboard would be noise. The dark override adds the CTA. + timeline: { titleKey: 'timeline.noEvents', subtitleKey: 'timeline.noEventsDesc' }, + screenshots: { + titleKey: 'screenshots.empty', + subtitleKey: 'screenshots.emptyDesc', + action: { ...CAPTURE_NOW } + }, + targets: { + titleKey: 'targets.empty', + subtitleKey: 'targets.subtitle', + action: { ...LEARN_MORE } + }, + loot: { + titleKey: 'loot.empty', + subtitleKey: 'loot.emptyDesc', + action: { ...SETUP_CAPTURE } + }, + marks: { + titleKey: 'marks.empty', + subtitleKey: 'marks.placeholderSub', + action: { ...MARK_NOW } + }, + // Transcript has no dedicated subtitle key; it is a second reading of the same + // event store, so it reuses the timeline's "events will appear here" copy. + transcript: { + titleKey: 'transcript.empty', + subtitleKey: 'timeline.noEventsDesc', + action: { ...LEARN_MORE } + } +} + +// When capture is dark these views cannot receive anything until a source is +// wired, so their CTA is redirected to "set up capture" regardless of their +// light-capture action. Screenshots is excluded — it IS a capture source, so +// "capture now" is still the right, self-contained action. +const DARK_REDIRECT: ReadonlySet = new Set([ + 'timeline', + 'loot', + 'targets', + 'transcript', + 'marks' +]) + +/** + * Decide the empty-state model for a view under the current capture context. + * Pure — no i18n resolution, no DOM, no side effects. + */ +export function emptyStateFor(view: EmptyView, ctx: EmptyStateCtx): EmptyStateModel { + const base = BASE[view] + if (ctx.captureDark && DARK_REDIRECT.has(view)) { + return { titleKey: base.titleKey, subtitleKey: base.subtitleKey, action: { ...SETUP_CAPTURE } } + } + // Clone the action so callers can't mutate the shared BASE table. + return { + titleKey: base.titleKey, + subtitleKey: base.subtitleKey, + action: base.action ? { ...base.action } : undefined + } +} diff --git a/src/renderer/src/lib/hud.ts b/src/renderer/src/lib/hud.ts index 1f0673af..5918efbf 100644 --- a/src/renderer/src/lib/hud.ts +++ b/src/renderer/src/lib/hud.ts @@ -15,6 +15,7 @@ export const HUD = { red: '#d75f63', // danger / exposed / recording green: '#5ecf9c', // safe amber: '#d4ac5a', // unknown / idle / partial + orange: '#d78550', // off_profile — observed deviation, short of a leak // neutrals muted: '#5f7a82', // labels value: '#cfe8ee', // primary values (cyan-tinted, not pure white) diff --git a/src/renderer/src/lib/icons.ts b/src/renderer/src/lib/icons.ts new file mode 100644 index 00000000..ed349742 --- /dev/null +++ b/src/renderer/src/lib/icons.ts @@ -0,0 +1,41 @@ +// Single source of truth for the app's semantic / entity glyphs. +// +// RedLog's icons are Unicode glyphs. They used to be hardcoded as string +// literals scattered across components — and the SAME entity drifted between +// files (loot `◆` appeared in Sidebar, StatusBar AND LootPanel's empty state; +// the transcript entity was `☰` in the sidebar but `▤` in its empty state; the +// marks entity was `⚑` in the sidebar but `◈` in its empty state). Centralising +// them here (DESIGN-SYSTEM.md §4.2 route B) makes the sidebar the canonical +// definition and stops the drift: one entity, one glyph, one place to change. +// +// Route B keeps the current visuals. The later route A — a real stroke icon set +// — swaps these values in ONE place without touching any call site. +// +// Convention: an icon is decorative. Render it with `aria-hidden` and ALWAYS +// keep a text label or `aria-label` beside it (the sidebar buttons already do). +// Size and colour come from the type scale + colour tokens (DESIGN-SYSTEM §2/§1), +// never from this map — it carries the glyph only. + +export const ICON = { + // ── Nav entities (the sidebar is the reference definition) ── + dashboard: '◉', + terminal: '▸', + timeline: '═', + transcript: '☰', + screenshots: '◻', + targets: '⊕', + scope: '⊘', + loot: '◆', + marks: '⚑', + settings: '⚙', + + // ── Cross-cutting affordances / semantic glyphs ── + search: '⌕', + dragHandle: '⠿', + anchor: '⚓', + laneAxis: '⊞', + phaseRibbon: '▤', + openInTimeline: '↗' +} as const + +export type IconName = keyof typeof ICON diff --git a/src/renderer/src/lib/ip-badge.ts b/src/renderer/src/lib/ip-badge.ts new file mode 100644 index 00000000..15466b50 --- /dev/null +++ b/src/renderer/src/lib/ip-badge.ts @@ -0,0 +1,42 @@ +// One decision, three surfaces (`ALERT-ROLES.md` A.3, G-A3). +// +// StatusBar, IPStatusCard and the overlay HUD each style themselves differently +// — tailwind classes, HUD hex, neon frame — but they must never DISAGREE about +// what the verdict is. Before this, `settling` and `stale` were not even +// declared on the renderer's IPStatus, so no surface could show them: a badge +// could sit on green while the reading behind it was 40 seconds dead. The +// styling stays local; the decision lives here. + +import { ipSeverity, type Severity } from './alertSeverity' + +export interface IPBadge { + /** Where this verdict sits on the SHARED scale the scope alarm also uses + * (G-C1). Fewer levels than verdicts on purpose: `presumed_safe` is `ok` + * like `safe`, and `qualified` is what separates them. */ + severity: Severity + /** Render de-emphasised (hollow / unglowing / no flash), never at full + * confidence. Two different things land here and both must: the READING is + * not current (`stale`, `settling`), or the reading is current but the + * JUDGEMENT is an inference (`presumed`). */ + qualified: boolean + reason: 'stale' | 'settling' | 'presumed' | null +} + +interface BadgeInput { + ipSafety?: 'safe' | 'presumed_safe' | 'off_profile' | 'exposed' | 'unknown' + settling?: boolean + stale?: boolean +} + +export function ipBadge(status: BadgeInput | null | undefined): IPBadge { + const verdict = status?.ipSafety ?? 'unknown' + const severity = ipSeverity(verdict) + + // Precedence runs from "how much do we know" outward. No reading at all beats + // an unconfirmed reading, which beats a confirmed reading we can only draw an + // inference from. All three qualify the badge; they differ in what to say. + if (status?.stale) return { severity: 'unknown', qualified: true, reason: 'stale' } + if (status?.settling) return { severity, qualified: true, reason: 'settling' } + if (verdict === 'presumed_safe') return { severity, qualified: true, reason: 'presumed' } + return { severity, qualified: false, reason: null } +} diff --git a/src/renderer/src/lib/laneVisibility.ts b/src/renderer/src/lib/laneVisibility.ts new file mode 100644 index 00000000..a307da78 --- /dev/null +++ b/src/renderer/src/lib/laneVisibility.ts @@ -0,0 +1,48 @@ +// Extracted verbatim (Phase C step 1) from Timeline.tsx's inline `populatedLanes` +// / `visibleLanes` memos + the solo derivation in the T3 modes memo, so the lane +// visibility rules have test coverage BEFORE the axis refactor changes the lane +// model. Zero behaviour change is the point. +// +// The mirrored inline code: +// populatedLanes = new Set(events.map(e => toLane(e.agentType, e.data?.subtype, pluginTypes))) +// visibleLanes = LANES.filter(l => populatedLanes.has(l) && !hiddenLanes.has(l)) +// isSolo = visibleLanes.length === 1 && hiddenLanes.size > 0 +// +// Kept pure by taking `laneOf` and the ordered lane list as parameters rather +// than importing Timeline's LANES/toLane — the seam must not depend on the +// component it will be wired back into. + +export type LaneId = string + +/** + * The set of lanes that have at least one event. Order-free (a Set), exactly + * like the inline memo; `visibleLanes` reimposes the canonical order. + */ +export function populatedLanes(events: readonly E[], laneOf: (e: E) => LaneId): Set { + const seen = new Set() + for (const e of events) seen.add(laneOf(e)) + return seen +} + +/** + * The lanes that get a row: populated AND not hidden, in the canonical `allLanes` + * order (not event/insertion order). Mirrors `LANES.filter(...)`. + */ +export function visibleLanes( + allLanes: readonly LaneId[], + populated: ReadonlySet, + hidden: ReadonlySet +): LaneId[] { + return allLanes.filter((l) => populated.has(l) && !hidden.has(l)) +} + +/** + * The soloed lane, or null. "Solo" is not stored directly — it is the derived + * state of exactly one lane surviving while others are explicitly hidden. Returns + * the lane id; the caller maps it to a label (the inline code does + * `laneLabels[visibleLanes[0]]`). Mirrors `isSolo` exactly: + * visible.length === 1 && hidden.size > 0 + */ +export function soloLaneOf(visible: readonly LaneId[], hidden: ReadonlySet): LaneId | null { + return visible.length === 1 && hidden.size > 0 ? visible[0] : null +} diff --git a/src/renderer/src/lib/phaseInference.ts b/src/renderer/src/lib/phaseInference.ts new file mode 100644 index 00000000..99756b90 --- /dev/null +++ b/src/renderer/src/lib/phaseInference.ts @@ -0,0 +1,130 @@ +// Phase-inference seam — DESIGN-PRINCIPLES §3, the SUGGESTION layer. +// +// This module is NEVER authoritative. §3 draws a hard line: RedLog records +// facts (an operator marker, or a command that primary capture proves ran), and +// treats every *interpretation* — including "which engagement phase is this?" — +// as a suggestion. So everything this function emits is `inferred`: it is +// confidence-scored, carries the id of the event that triggered it, and is meant +// to be promoted by the operator into a real `marker` with one click. It must +// never mutate an event or be shown as ground truth (§3: dashed segment, not +// solid). Downgrade, don't assert. +// +// It is deliberately NOT an opinionated ATT&CK engine. It is a light, defensible +// "event type → phase signal" heuristic that stays conservative on purpose: +// where a mapping is weak (an untagged transfer, a page load), the confidence is +// `low`, and unrecognised event types produce nothing at all. The seam marks +// phase *boundaries* — where a phase begins — not every event inside a phase. + +export type Confidence = 'low' | 'medium' | 'high' + +export interface EventLike { + id: string + timestamp: number + agentType: string + data?: { subtype?: string; direction?: string } +} + +export interface InferredPhase { + ts: number + phase: string + confidence: Confidence + reason: string + /** id of the event that triggered this suggestion — the promotion anchor. */ + sourceEventId: string +} + +// The signal a single event carries about the current phase. Kept separate from +// InferredPhase because ts/sourceEventId belong to the event, not the mapping. +interface PhaseSignal { + phase: string + confidence: Confidence + reason: string +} + +// Map one event to a phase signal, or null if the event type carries no signal. +// Conservative by construction: a value only appears here if it is defensible in +// a report, and ambiguous cases resolve to the lower-confidence reading. +function signalFor(ev: EventLike): PhaseSignal | null { + switch (ev.agentType) { + case 'pivot': + // A tunnel/pivot is an unambiguous lateral-movement act — high confidence. + return { phase: 'lateral-movement', confidence: 'high', reason: 'pivot/tunnel detected' } + + case 'file_transfer': { + // Direction decides the phase. Treat an explicit exfil direction OR an + // exfil-flavoured subtype as data leaving; everything else (ingress, or an + // untagged transfer) is conservatively read as delivery. + const dir = ev.data?.direction ?? '' + const sub = ev.data?.subtype ?? '' + const looksExfil = dir.includes('exfil') || sub.includes('exfil') + if (looksExfil) { + return { phase: 'exfil', confidence: 'medium', reason: 'outbound file transfer' } + } + return { phase: 'delivery', confidence: 'low', reason: 'inbound file transfer' } + } + + case 'cleanup': + // Anti-forensics is a distinct, high-signal act (NIST SP 800-86). + return { phase: 'anti-forensics', confidence: 'high', reason: 'cleanup/anti-forensics action' } + + case 'credential_use': + case 'loot': + return { + phase: 'credential-access', + confidence: 'medium', + reason: 'credential use or loot detected' + } + + case 'scanner': + case 'dns': + case 'http_navigation': + // Weak, common signals — recon at low confidence only. + return { phase: 'recon', confidence: 'low', reason: 'scan/dns/navigation activity' } + + default: + return null + } +} + +/** + * Derive phase-boundary suggestions from a list of events (§3 suggestion layer). + * + * Processes events in ascending ts order (the input is sorted first, so callers + * need not pre-sort). Collapses adjacent runs of the same phase to a single + * boundary — we mark where a phase *starts*, not every event inside it — so a + * suggestion's ts and sourceEventId point at the FIRST event of its run. A phase + * that recurs after a different phase intervenes is a new, kept boundary. + * + * Empty input → []. Nothing here is authoritative; see the file header. + */ +export function inferPhaseSuggestions(events: EventLike[]): InferredPhase[] { + if (events.length === 0) return [] + + // Sort a copy — never mutate the caller's array. Stable enough for ties: we + // only need monotonic ts ordering to find boundaries. + const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp) + + const out: InferredPhase[] = [] + let prevPhase: string | null = null + + for (const ev of sorted) { + const signal = signalFor(ev) + if (signal === null) continue // unmapped event type: no suggestion + + // Adjacent de-dup: only the first event of a run of the same phase becomes a + // boundary. An unmapped event between two same-phase events does not break + // the run — prevPhase is untouched when we `continue` above. + if (signal.phase === prevPhase) continue + + out.push({ + ts: ev.timestamp, + phase: signal.phase, + confidence: signal.confidence, + reason: signal.reason, + sourceEventId: ev.id + }) + prevPhase = signal.phase + } + + return out +} diff --git a/src/renderer/src/lib/phaseSegments.ts b/src/renderer/src/lib/phaseSegments.ts new file mode 100644 index 00000000..a2be25c3 --- /dev/null +++ b/src/renderer/src/lib/phaseSegments.ts @@ -0,0 +1,97 @@ +// The AUTHORITATIVE half of phase reconstruction. DESIGN-PRINCIPLES §3 says RedLog +// records facts and never writes interpretation as fact: authoritative assertions +// come from exactly two places — the operator (a marker) or primary capture. §8/§11 +// make phase an operator axis of the review timeline, and split its derivation in two. +// This module is the "乙" half: phase segments derived *purely* from the operator's +// own phase-change markers. Each such marker is one deliberate, on-chain, attributable +// claim about where a phase begins — so this seam is authoritative and drawn solid. +// The other half (auto-inferred phase, rendered as dashed suggestions) is a separate +// seam and deliberately lives nowhere near this file. +// +// Everything here is a pure value → value transform: no clock, no event store, no +// React. That keeps the authoritative segmentation fully testable and impossible to +// contaminate with live-monitor state (§8: the timeline is a reconstruction surface). + +/** One operator phase-change claim: at `ts`, the engagement entered `phase`. */ +export interface PhaseMarker { + ts: number + phase: string + /** id of the originating on-chain marker event — this is what makes the claim attributable. */ + markerId: string +} + +/** A contiguous stretch of the timeline the operator has assigned to one phase. */ +export interface PhaseSegment { + phase: string + start: number + /** where the phase ends; `null` means still in progress (no later marker, no known domain end). */ + end: number | null + /** the marker that opened this segment — carried through so the UI can attribute/link it. */ + markerId: string +} + +// Pure segmentation. Markers are sorted by ts (callers often hand them to us in +// event-store order, but a segmentation is a statement about time). Each marker opens +// a segment whose end is the NEXT marker's ts, so the result is contiguous and +// non-overlapping. The final segment ends at `domainEnd` when the caller knows the +// engagement's right edge (e.g. the last event's ts), otherwise it is left open (null). +// +// Adjacent markers naming the same phase are intentionally NOT merged: re-asserting a +// phase is a distinct operator act with its own markerId and its own chain event, and +// collapsing them would erase that the operator drew the boundary. Two claims, two +// segments — even if the phase string is identical. +export function phaseSegments(markers: PhaseMarker[], domainEnd?: number): PhaseSegment[] { + if (markers.length === 0) return [] + + // Copy before sorting — never mutate the caller's array. + const sorted = [...markers].sort((a, b) => a.ts - b.ts) + + return sorted.map((m, i) => { + const next = sorted[i + 1] + // Interior segments close at the next claim's ts; the last one closes at the known + // domain end, or stays open (null) when the end is unknown. + const end = next ? next.ts : domainEnd ?? null + return { phase: m.phase, start: m.ts, end, markerId: m.markerId } + }) +} + +/** The shape of a raw event this seam needs — a structural subset of the full event row. */ +export interface MarkerEventLike { + id: string + timestamp: number + agentType: string + data?: { + subtype?: string + phase?: string + category?: string + title?: string + } +} + +// Extract the phase-change markers from a stream of raw events — the input side of the +// authoritative seam. We keep only `marker` events (operator assertions, §3) whose data +// signals a phase change, and drop everything else: shell commands, plain finding-note +// markers, inferred detections. A phase change is signalled any of three equivalent ways +// so callers/importers aren't forced into one field: an explicit `subtype: 'phase'`, a +// populated `phase`, or `category: 'phase'`. +// +// The phase name prefers the canonical `data.phase`; a phase marker created through the +// generic marker UI may only carry a `title`, so title is the fallback. Output is sorted +// by ts so it can be fed straight into `phaseSegments`. +export function phaseMarkersFromEvents(events: MarkerEventLike[]): PhaseMarker[] { + return events + .filter((e) => { + if (e.agentType !== 'marker') return false + const d = e.data + if (!d) return false + return d.subtype === 'phase' || d.phase !== undefined || d.category === 'phase' + }) + .map((e) => ({ + ts: e.timestamp, + // `?? ''` is a defensive floor: a phase marker with neither field is malformed, + // but we still return a (empty-named) marker rather than throwing on bad data. + phase: e.data?.phase ?? e.data?.title ?? '', + markerId: e.id + })) + .sort((a, b) => a.ts - b.ts) +} diff --git a/src/renderer/src/lib/settingsSearch.ts b/src/renderer/src/lib/settingsSearch.ts new file mode 100644 index 00000000..d7616bc0 --- /dev/null +++ b/src/renderer/src/lib/settingsSearch.ts @@ -0,0 +1,34 @@ +// Pure matcher behind the Settings filter box (UX-BACKLOG F3 / DESIGN-PRINCIPLES +// §9). Settings is 8 tabs of FieldGroups and the only way to find a setting is +// remembering its tab; this seam lets the header input filter groups by title +// and field-label text so the operator can "find it" instead of re-tabbing. +// +// The index is static presentation data — one entry per FieldGroup, carrying its +// owning tab, a stable id, its resolved (localized) title, and the resolved +// labels of the fields inside it. Keeping the match rule out of the component +// keeps the behaviour tested and the component thin. + +export interface SettingsGroupIndex { + tab: string + groupId: string + title: string + labels: string[] +} + +/** + * Filter a settings group index by a free-text query. + * + * An empty (or whitespace-only) query is "no filter" and returns the index + * unchanged, so the caller can fall back to its normal tabbed view. Otherwise a + * group matches when its title OR any of its labels contains the query as a + * case-insensitive substring. Matching groups are returned in their original + * index order; no match returns []. + */ +export function matchGroups(query: string, index: SettingsGroupIndex[]): SettingsGroupIndex[] { + const needle = query.trim().toLowerCase() + if (needle === '') return index + return index.filter((group) => { + if (group.title.toLowerCase().includes(needle)) return true + return group.labels.some((label) => label.toLowerCase().includes(needle)) + }) +} diff --git a/src/renderer/src/lib/shortcuts.ts b/src/renderer/src/lib/shortcuts.ts new file mode 100644 index 00000000..b10cc945 --- /dev/null +++ b/src/renderer/src/lib/shortcuts.ts @@ -0,0 +1,70 @@ +// Single source of truth for RedLog's keyboard/pointer shortcuts, so the two +// places that surface them — the Dashboard "shortcuts" card (App.tsx) and the +// Timeline `?` cheatsheet (Timeline.tsx) — render from ONE list instead of two +// hand-maintained arrays that drifted apart (UX backlog F5: the card and the +// cheatsheet disagreed on which keys existed and what they did). +// +// This module is intentionally presentation-only: it does NOT wire up handlers +// (App.tsx and lib/timelineKeys.ts still own the actual keydown logic). It just +// describes what to show. Keeping it a plain data table means the two views +// can't drift, and the list is trivially testable. + +export type ShortcutScope = 'global' | 'timeline' + +export interface Shortcut { + /** Stable machine identifier — never shown to the user, safe to key React on. */ + id: string + /** Human-readable key combo. The modifier is the literal placeholder token + * `Mod` (see MOD_TOKEN); the render side swaps it for modKey(platform) so the + * registry stays platform-agnostic. Pointer affordances read literally + * (e.g. 'Alt+click', 'Right-click'). */ + keys: string + /** Stable label identifier for the render side to map to display text. This is + * NOT an i18n key — callers already have their own translated strings for + * these actions; this only has to be a consistent, collision-free token. */ + labelKey: string + scope: ShortcutScope +} + +/** Placeholder the registry uses in place of a real modifier symbol. The render + * side replaces it with modKey(process.platform). */ +export const MOD_TOKEN = 'Mod' + +// Enumerated from the live handlers: +// - global keys: App.tsx onKeyDown (⌘1..9 view nav, ⌘/ search, ⌘. pause) and +// the main-process globalShortcut for ⌘⇧M quick marker. +// - timeline keys: lib/timelineKeys.ts resolver (`/`, `?`, `f`, ↑/↓) plus the +// ⌘K palette handler and the pointer affordances (Alt-click solo lane, +// right-click drop marker) rendered in Timeline.tsx's cheatsheet. +export const SHORTCUTS: Shortcut[] = [ + // --- global (available everywhere) --- + { id: 'switch-view', keys: `${MOD_TOKEN}+1..9`, labelKey: 'shortcut.switchView', scope: 'global' }, + { id: 'quick-marker', keys: `${MOD_TOKEN}+Shift+M`, labelKey: 'shortcut.quickMarker', scope: 'global' }, + { id: 'search', keys: `${MOD_TOKEN}+/`, labelKey: 'shortcut.search', scope: 'global' }, + { id: 'pause-recording', keys: `${MOD_TOKEN}+.`, labelKey: 'shortcut.pauseRecording', scope: 'global' }, + + // --- timeline (only meaningful in the Timeline view) --- + { id: 'filter-events', keys: '/', labelKey: 'shortcut.filterEvents', scope: 'timeline' }, + { id: 'toggle-help', keys: '?', labelKey: 'shortcut.toggleHelp', scope: 'timeline' }, + { id: 'focus-chain', keys: 'f', labelKey: 'shortcut.focusChain', scope: 'timeline' }, + { id: 'command-palette', keys: `${MOD_TOKEN}+K`, labelKey: 'shortcut.commandPalette', scope: 'timeline' }, + { id: 'navigate-events', keys: '↑/↓', labelKey: 'shortcut.navigateEvents', scope: 'timeline' }, + { id: 'solo-lane', keys: 'Alt+click', labelKey: 'shortcut.soloLane', scope: 'timeline' }, + { id: 'drop-marker', keys: 'Right-click', labelKey: 'shortcut.dropMarker', scope: 'timeline' } +] + +/** Shortcuts relevant to a scope. 'global' returns only the always-on keys; + * 'timeline' returns those global keys PLUS the timeline-only ones, since the + * Timeline view inherits every global shortcut. Order is preserved (globals + * first) so the render side gets a stable, sensible reading order. */ +export function shortcutsForScope(scope: ShortcutScope): Shortcut[] { + if (scope === 'global') return SHORTCUTS.filter((s) => s.scope === 'global') + return SHORTCUTS.filter((s) => s.scope === 'global' || s.scope === scope) +} + +/** The modifier symbol to substitute for MOD_TOKEN. macOS shows ⌘; everything + * else shows Ctrl. Takes the platform string (e.g. process.platform) so it + * stays a pure function and is trivially testable. */ +export function modKey(platform: string): string { + return platform === 'darwin' ? '⌘' : 'Ctrl' +} diff --git a/src/renderer/src/lib/splitPane.ts b/src/renderer/src/lib/splitPane.ts new file mode 100644 index 00000000..1f43c196 --- /dev/null +++ b/src/renderer/src/lib/splitPane.ts @@ -0,0 +1,23 @@ +// The pure decision behind 's drag (DESIGN-SYSTEM.md §6.2). Given a +// desired pixel size for the resizable pane, clamp it so it (a) stays within the +// component's [min, max], and (b) never starves the OTHER pane below `otherMin` +// px of the current container. Lifting this out of the drag handler makes the +// bounds written down and testable, mirroring lib/timelineWheel.ts. +// +// Callers compute `px` (start size + pointer delta) and `containerPx` (the split +// container's measured extent along the drag axis) and apply the returned size. + +export function splitPaneClamp( + px: number, + min: number, + max: number, + containerPx: number, + otherMin = 120 +): number { + // The largest the resizable pane may grow to: its own `max`, but never so far + // that the other pane drops below `otherMin`. Floor it at `min` so a tiny + // container can't invert the range (hardMax < min) — in that degenerate case + // `min` wins and the other pane simply gets whatever is left. + const hardMax = Math.max(min, Math.min(max, containerPx - otherMin)) + return Math.max(min, Math.min(px, hardMax)) +} diff --git a/src/renderer/src/lib/targetGrouping.ts b/src/renderer/src/lib/targetGrouping.ts new file mode 100644 index 00000000..81f5ceb2 --- /dev/null +++ b/src/renderer/src/lib/targetGrouping.ts @@ -0,0 +1,77 @@ +// The target axis for the reconstruction timeline (DESIGN-PRINCIPLES §8/§9): review +// asks "what happened to *this target*", so the timeline organises by target instead +// of by source-type lane. This pure seam is that axis's data source — it turns a flat +// event list into per-target groups, ordered the way the axis draws them. +// +// §12: EXPLICIT untargeted bucket — never orphan, never guess. Attribution here is +// conservative: an event only joins a target group when it carries a concrete targetId. +// Anything ambiguous — targetId null, undefined, or '' — is NOT guessed onto a nearby +// target; it drops into one explicit `target: null` "untargeted" bucket. That bucket +// is a real, addressable group (so nothing is orphaned) and always sorts LAST, so it +// reads as the residual pile rather than as just another target. + +export interface EventLike { + id: string + timestamp: number + targetId?: string | null +} + +export interface TargetGroup { + /** The concrete targetId, or null for the explicit untargeted bucket (§12). */ + target: string | null + /** Member event ids, ascending by timestamp. */ + eventIds: string[] + /** Timestamp of the group's earliest event. */ + firstTs: number + /** Timestamp of the group's latest event. */ + lastTs: number +} + +const UNTARGETED = null + +// null/undefined/'' all mean "no concrete target" → the untargeted bucket. Anything +// else is taken as an authoritative target id. +function targetKeyOf(event: EventLike): string | null { + const t = event.targetId + if (t === null || t === undefined || t === '') return UNTARGETED + return t +} + +/** + * Group events by targetId. Events without a concrete target collapse into a single + * explicit `target: null` bucket. Targeted groups are ordered by their earliest event + * timestamp (ascending); the untargeted bucket, if present, always comes last. Within + * every group eventIds are sorted ascending by timestamp, and firstTs/lastTs bound the + * group's time range. Empty input yields []. + */ +export function groupByTarget(events: readonly EventLike[]): TargetGroup[] { + // Bucket events by target while preserving nothing but membership; sorting happens + // once at the end so unsorted input still produces correct group and member order. + const buckets = new Map() + for (const event of events) { + const key = targetKeyOf(event) + const bucket = buckets.get(key) + if (bucket) bucket.push(event) + else buckets.set(key, [event]) + } + + const groups: TargetGroup[] = [] + for (const [target, members] of buckets) { + const sorted = [...members].sort((a, b) => a.timestamp - b.timestamp) + groups.push({ + target, + eventIds: sorted.map((e) => e.id), + firstTs: sorted[0].timestamp, + lastTs: sorted[sorted.length - 1].timestamp + }) + } + + // Group order: untargeted bucket always last (§12); targeted groups by firstTs asc. + groups.sort((a, b) => { + if (a.target === UNTARGETED) return 1 + if (b.target === UNTARGETED) return -1 + return a.firstTs - b.firstTs + }) + + return groups +} diff --git a/src/renderer/src/lib/timelineAxis.ts b/src/renderer/src/lib/timelineAxis.ts new file mode 100644 index 00000000..def1be0b --- /dev/null +++ b/src/renderer/src/lib/timelineAxis.ts @@ -0,0 +1,105 @@ +import { groupByTarget } from './targetGrouping' + +// Phase C step 2 (SPEC-TIMELINE-AXIS): the timeline's swim-lane rows can come +// from either the current source-type lanes OR a target grouping. This seam is +// the single place that decision lives, so the renderer maps events → rows the +// same way regardless of axis. +// +// The 'source' axis is a deliberate pass-through: it returns the lanes the +// caller already computes (populated, canonical order) and defers lane-of to the +// caller's existing toLane. That is what makes shipping 'source' as the default a +// byte-for-byte no-op — nothing changes until an operator opts into 'target'. +// +// The 'target' axis wraps the Phase B groupByTarget (untargeted bucket always +// last, §12: never orphan, never guess). laneOfEvent MUST agree with +// lanesForAxis on ids, so every event lands in exactly one rendered lane. + +export type LaneAxis = 'source' | 'target' + +export interface AxisLane { + id: string + label: string +} + +// Minimal event shape both axes need. The functions are generic over the +// caller's concrete event type (e.g. RedLogEvent), so no open index signature is +// needed — sourceLaneOf reads whatever fields it wants off the concrete type. +export interface AxisEvent { + id: string + timestamp: number + targetId?: string | null +} + +/** Stable id for the catch-all lane holding events with no target (§12). */ +export const UNTARGETED_LANE = '__untargeted__' + +/** + * The ordered lane descriptors for an axis. + * source → the caller's lanes, unchanged (the zero-change default). + * target → one lane per target from groupByTarget, untargeted last, labelled + * with the target id (or `untargetedLabel` for the null bucket). + */ +export function lanesForAxis( + axis: LaneAxis, + events: readonly AxisEvent[], + sourceLanes: readonly AxisLane[], + untargetedLabel: string +): AxisLane[] { + if (axis === 'source') return [...sourceLanes] + return groupByTarget(events).map((g) => ({ + id: g.target ?? UNTARGETED_LANE, + label: g.target ?? untargetedLabel + })) +} + +/** + * Which lane a single event belongs to under an axis. Agrees with lanesForAxis: + * source → the caller's sourceLaneOf (existing toLane). + * target → the event's target id, or UNTARGETED_LANE for null/undefined/''. + */ +export function laneOfEvent( + axis: LaneAxis, + event: E, + sourceLaneOf: (e: E) => string +): string { + if (axis === 'source') return sourceLaneOf(event) + const t = event.targetId + return t == null || t === '' ? UNTARGETED_LANE : t +} + +export interface LaneModel { + /** ordered lane descriptors for the axis */ + lanes: AxisLane[] + /** which lane a given event belongs to (agrees with `lanes`) */ + laneOf: (e: E) => string + /** events grouped by lane id; every lane in `lanes` has an entry (possibly []), + * mirroring the inline `Object.fromEntries(LANES.map(...))` seeding so callers + * can index `laneEvents[lane]` for any lane without an undefined check */ + laneEvents: Record +} + +/** + * The complete lane model for an axis in one pass: the ordered lanes, the + * event→lane mapping, and events grouped by lane (seeded so every lane has an + * array). `source` reproduces the inline behaviour exactly (lanes = sourceLanes, + * grouped by sourceLaneOf); `target` groups by target with the untargeted lane + * last. Pure — the renderer supplies sourceLanes / sourceLaneOf. + */ +export function buildLaneModel( + axis: LaneAxis, + events: readonly E[], + sourceLanes: readonly AxisLane[], + sourceLaneOf: (e: E) => string, + untargetedLabel: string +): LaneModel { + const lanes = lanesForAxis(axis, events, sourceLanes, untargetedLabel) + const laneOf = (e: E): string => laneOfEvent(axis, e, sourceLaneOf) + const laneEvents: Record = {} + for (const l of lanes) laneEvents[l.id] = [] + for (const e of events) { + const id = laneOf(e) + // target ids are data-derived, so a lane not pre-seeded can appear; guard. + ;(laneEvents[id] ??= []).push(e) + } + return { lanes, laneOf, laneEvents } +} diff --git a/src/renderer/src/lib/timelineCluster.ts b/src/renderer/src/lib/timelineCluster.ts new file mode 100644 index 00000000..ecf6a9e6 --- /dev/null +++ b/src/renderer/src/lib/timelineCluster.ts @@ -0,0 +1,89 @@ +// The Timeline collapses events that fall within ~14px of each other on the same +// lane into a single clickable marker (with a count) so dense bursts stay +// legible. Zooming in widens the track, so clusters naturally split apart into +// individual dots. This is the pure core of that bucketing, extracted verbatim +// from Timeline.tsx's `clusters` memo so it can be pinned by tests before the +// timeline axis refactor (SPEC-TIMELINE-AXIS Step 1). Zero behaviour change is +// the whole point: same inputs must yield the same clusters as the inline code. +// +// Faithfulness notes on the mirrored semantics: +// * Grid bucketing, NOT pairwise distance. Each item lands in bin +// `Math.floor(x / mergePx)`; only *consecutive* items sharing a bin index +// merge. Two items 2px apart still split if they straddle a bin boundary +// (e.g. x=13 and x=15 → bins 0 and 1). This mirrors the inline code exactly. +// * Input order is preserved and never re-sorted. The inline code relies on +// `laneEvents[lane]` already being time-sorted; this function makes the same +// assumption. Callers must supply each lane's items in the same (sorted) +// order the Timeline already flattens them in. +// * Lane grouping is stable by first appearance, matching the inline code's +// per-lane `visibleLanes.forEach` walk where the caller flattens visible +// lanes in row order and each lane's events are contiguous. + +export interface ClusterInput { + id: string + /** the lane the event belongs to (source-type row today) */ + lane: string + /** the event's pixel x on the track — i.e. the inline code's `toX(displayTs(e))` */ + x: number +} + +export interface Cluster { + /** stable key: `${lane}-${firstId}`, verbatim from the inline `out.push` */ + key: string + lane: string + /** representative x: the arithmetic mean of the member pixel xs */ + x: number + /** member event ids, in input order */ + ids: string[] + /** member count (== ids.length); drives the dot's count badge */ + count: number +} + +// Default merge distance in pixels — the inline `CLUSTER_PX = 14`. +const CLUSTER_PX = 14 + +// Collapse same-lane, near-adjacent events into counted clusters. +// +// The returned li/y row geometry the inline memo also computes +// (`y = li * laneH + laneH / 2`) is intentionally NOT produced here: it is a +// rendering transform over the lane's row index, not part of the clustering, and +// the consumer recomputes it from the row position. Everything that is purely a +// function of the bucketing — key, lane, mean x, ids, count — lives here. +export function clusterEvents(items: ClusterInput[], mergePx: number = CLUSTER_PX): Cluster[] { + // Group by lane, stable in first-seen order, preserving per-lane input order. + // Mirrors the inline `visibleLanes.forEach` where each lane is visited once in + // row order and its events are already contiguous and time-sorted. + const byLane = new Map() + for (const item of items) { + const laneItems = byLane.get(item.lane) + if (laneItems) laneItems.push(item) + else byLane.set(item.lane, [item]) + } + + const out: Cluster[] = [] + for (const [lane, evs] of byLane) { + if (!evs.length) continue + let bucket: ClusterInput[] = [] + let curBi = NaN + const flush = (): void => { + if (!bucket.length) return + const x = bucket.reduce((a, e) => a + e.x, 0) / bucket.length + out.push({ + key: `${lane}-${bucket[0].id}`, + lane, + x, + ids: bucket.map((e) => e.id), + count: bucket.length + }) + bucket = [] + } + for (const e of evs) { + const bi = Math.floor(e.x / mergePx) + if (bucket.length && bi !== curBi) flush() + curBi = bi + bucket.push(e) + } + flush() + } + return out +} diff --git a/src/renderer/src/lib/timelineModes.ts b/src/renderer/src/lib/timelineModes.ts new file mode 100644 index 00000000..25f409ef --- /dev/null +++ b/src/renderer/src/lib/timelineModes.ts @@ -0,0 +1,91 @@ +// One pure derivation for the Timeline's "Active:" row (DESIGN-TIMELINE- +// INTERACTION §T3). The track carries several sticky, non-default modes that each +// HIDE or TRANSFORM events — focus chain, anomaly filter, agent-turn collapse, +// lane solo, hidden lanes, text filter. Left on, they leave the operator staring +// at a near-empty track with no explanation (DESIGN-PRINCIPLES §8: an empty track +// must explain itself). This function turns the current mode state into the exact +// set of dismissible chips the row renders — one per non-default mode, in a fixed +// order — and returns [] when everything is default so the UI drops the row +// entirely instead of drawing an empty bar. +// +// Scope note: follow mode is deliberately absent. It auto-scrolls but hides no +// data, so per the design's open-question #3 it stays a header toggle, not a chip +// — and keeping it off TimelineModeState keeps it out of this seam by construction. + +export interface TimelineModeState { + /** focus-chain mode is active (isolates one causal chain) */ + focusActive: boolean + /** anomaly filter is on (only flagged events shown) */ + anomalyOnly: boolean + /** agent turns are collapsed (multi-event turns folded to one) */ + collapseAgentTurns: boolean + /** a single lane is soloed (null = no solo); every other lane is hidden */ + soloLane: string | null + /** how many lanes are hidden by the operator (0 = none) */ + hiddenLaneCount: number + /** the '/' text filter query ('' = no filter) */ + filterQuery: string +} + +export interface ModeChip { + /** stable identity for the mode, drives keying and the chip's icon */ + id: string + /** stable i18n identifier — resolved by the caller, never registered here */ + labelKey: string + /** action id the chip's ✕ dispatches to clear just this mode */ + clearAction: string + /** optional dynamic payload (lane name, hidden count, query text) */ + value?: string +} + +export function activeModes(state: TimelineModeState): ModeChip[] { + const chips: ModeChip[] = [] + + // Fixed order: focus → anomaly → collapse-agent → solo/hidden → filter. The + // order is part of the contract so the row never reshuffles between renders. + if (state.focusActive) { + chips.push({ id: 'focus', labelKey: 'timeline.mode.focus', clearAction: 'clear-focus' }) + } + if (state.anomalyOnly) { + chips.push({ id: 'anomaly', labelKey: 'timeline.mode.anomaly', clearAction: 'clear-anomaly' }) + } + if (state.collapseAgentTurns) { + chips.push({ + id: 'collapse-agent', + labelKey: 'timeline.mode.collapseAgent', + clearAction: 'clear-collapse-agent' + }) + } + + // Solo and hidden-lanes are mutually exclusive in the row: a soloed lane + // already hides every other lane, so the hidden count is redundant noise while + // solo is on. Show the sharper fact (which lane) and suppress the count. + if (state.soloLane !== null) { + chips.push({ + id: 'solo', + labelKey: 'timeline.mode.solo', + clearAction: 'clear-solo', + value: state.soloLane + }) + } else if (state.hiddenLaneCount > 0) { + chips.push({ + id: 'hidden-lanes', + labelKey: 'timeline.mode.hiddenLanes', + clearAction: 'clear-hidden-lanes', + value: String(state.hiddenLaneCount) + }) + } + + // Emptiness is strictly '' — a query of only spaces is an odd but real filter, + // so it still earns a chip and its text is carried verbatim. + if (state.filterQuery !== '') { + chips.push({ + id: 'filter', + labelKey: 'timeline.mode.filter', + clearAction: 'clear-filter', + value: state.filterQuery + }) + } + + return chips +} diff --git a/src/renderer/src/lib/timelineWheel.ts b/src/renderer/src/lib/timelineWheel.ts new file mode 100644 index 00000000..24a16f5d --- /dev/null +++ b/src/renderer/src/lib/timelineWheel.ts @@ -0,0 +1,46 @@ +// One pure decision for what a wheel event does over the Timeline track. It +// replaces the invisible branch in Timeline.tsx's wheel handler where a plain +// wheel silently flipped between horizontal pan and vertical scroll depending +// on whether the lane stack overflowed the viewport — the surprising behaviour +// nobody could see or reason about. Lifting the decision into a pure function of +// (overflow, shiftKey, zoomKey) makes the precedence written down, testable, and +// impossible to get into an ambiguous state; it is also exactly the table that +// T2(b) annotates on-screen for the one row that surprises operators. +// +// Style note: mirrors lib/timelineKeys.ts — a single pure resolver, precedence +// commented row by row, no DOM or event coupling. Callers compute the three +// booleans (overflow = laneStack.scrollHeight > clientHeight + 1) and act on the +// returned mode. + +export interface WheelContext { + /** the lane stack is taller than its viewport: scrollHeight > clientHeight + 1 */ + overflow: boolean + /** the Shift key is held — overrides scroll-y back to horizontal pan */ + shiftKey: boolean + /** the zoom modifier is held: ctrlKey || metaKey (trackpad pinch reports ctrl) */ + zoomKey: boolean +} + +export type WheelMode = + | 'zoom' // cursor-anchored zoom (zoom modifier wins over everything) + | 'pan-x' // horizontal pan of the time axis (the common, unsurprising case) + | 'scroll-y' // vertical scroll to reach lanes clipped below the fold + +export function wheelMode(ctx: WheelContext): WheelMode { + // The zoom modifier wins unconditionally — shift and overflow are irrelevant + // once the operator is asking to zoom. This is the top row of the T2 matrix. + if (ctx.zoomKey) return 'zoom' + + // With no overflow the stack already fits, so vertical scroll is meaningless; + // a plain wheel always pans the time axis. This is the common case, unchanged. + if (!ctx.overflow) return 'pan-x' + + // Overflow from here down. Shift is the explicit escape hatch: hold it to keep + // panning the time axis instead of scrolling the clipped lanes into view. + if (ctx.shiftKey) return 'pan-x' + + // Overflow, no modifier: the one surprising row. A plain wheel scrolls the lane + // stack so the operator can reach lanes clipped below the fold — and T2(b)'s + // inline hint appears precisely here to explain the gesture as it happens. + return 'scroll-y' +} diff --git a/src/renderer/src/styles/index.css b/src/renderer/src/styles/index.css index 6bac813a..278a5962 100644 --- a/src/renderer/src/styles/index.css +++ b/src/renderer/src/styles/index.css @@ -54,7 +54,30 @@ body { } .drag-over { - border-top: 2px solid #ef4444 !important; + border-top: 2px solid #d75f63 !important; /* soften map red-400 (DESIGN-SYSTEM §1.5) */ +} + +/* Hit-slop: enlarge a small control's pointer/click target WITHOUT growing its + * visual box, via a transparent ::before overlay. Apple HIG asks for 44pt touch + * targets; RedLog is a dense pointer-driven desktop tool, so we set a desktop + * floor of ~28px (see docs/DESIGN-SYSTEM.md §0.1, audit C8). The overlay claims + * the surrounding dead space, so keep two hit-target controls ≥12px apart — the + * slops then meet only in the gap between them, never over a neighbour's glyph. + * The control must be able to position the overlay: `.hit-target` sets + * position:relative for you (a button is static by default). */ +.hit-target { position: relative; } +.hit-target::before { + content: ''; + position: absolute; + inset: -0.375rem; /* -6px each side: a 16px control → 28px, a 20px control → 32px */ +} +/* Vertical-only slop for thin, full-width drag handles (e.g. the 4px detail- + * panel grip) — grows the grab band without widening it into neighbours. */ +.hit-target-v { position: relative; } +.hit-target-v::before { + content: ''; + position: absolute; + inset: -0.5rem 0; /* -8px top/bottom only: a 4px handle → 20px grab height */ } .timeline-grabbing { diff --git a/tailwind.config.js b/tailwind.config.js index 0cd9ceaf..1a4ddf8a 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -12,6 +12,9 @@ const soften = { emerald: { ...colors.emerald, 300: '#8fddb6', 400: '#5ecf9c', 500: '#4bbf8a' }, green: { ...colors.green, 300: '#8fddb6', 400: '#5ecf9c', 500: '#4bbf8a' }, amber: { ...colors.amber, 300: '#e2c886', 400: '#d4ac5a', 500: '#c69a45' }, + // off_profile — sits between red (exposed) and amber (unknown), softened to + // the same degree so it doesn't vibrate next to them. + orange: { ...colors.orange, 300: '#e3ab84', 400: '#d78550', 500: '#c9773f' }, yellow: { ...colors.yellow, 300: '#e2c886', 400: '#d4ac5a', 500: '#c69a45' }, cyan: { ...colors.cyan, 300: '#7fe0ea', 400: '#3fc7d6', 500: '#2ba9b8' } } @@ -37,11 +40,23 @@ module.exports = { 'text-dim': '#a1a1aa' } }, + // Two named micro tiers so the sub-`text-xs` labels stop being hardcoded + // `text-[10px]`/`text-[9px]`/`text-[8px]` px (which only scaled with + // --app-zoom, not the 17px base). String form = font-size only, matching + // the arbitrary-value behaviour these replace. See docs/DESIGN-SYSTEM.md §2.3. + fontSize: { + '2xs': '0.625rem', // ~10.6px @17px base — chip counts, freshness, minor meta + '3xs': '0.5rem' // ~8.5px @17px base — the densest badges/labels + }, boxShadow: { - 'glow-red': '0 0 12px 0 rgba(239, 68, 68, 0.15)', - 'glow-red-sm': '0 0 6px 0 rgba(239, 68, 68, 0.1)', - 'glow-cyan': '0 0 14px 0 rgba(34, 211, 238, 0.18)', - 'glow-cyan-sm': '0 0 6px 0 rgba(34, 211, 238, 0.25)', + // Softened to match the `soften` accent map (red-400 #d75f63 = rgb(215,95,99), + // cyan-400 #3fc7d6 = rgb(63,199,214)) so glows no longer render the pre-soften + // bright #ef4444 / #22d3ee that "vibrates" against the dark surface. See + // docs/DESIGN-SYSTEM.md §1.5. + 'glow-red': '0 0 12px 0 rgba(215, 95, 99, 0.15)', + 'glow-red-sm': '0 0 6px 0 rgba(215, 95, 99, 0.1)', + 'glow-cyan': '0 0 14px 0 rgba(63, 199, 214, 0.18)', + 'glow-cyan-sm': '0 0 6px 0 rgba(63, 199, 214, 0.25)', 'card': '0 1px 3px 0 rgba(0, 0, 0, 0.4)', 'card-hover': '0 4px 12px 0 rgba(0, 0, 0, 0.5)' }, diff --git a/test/alert-display.test.tsx b/test/alert-display.test.tsx new file mode 100644 index 00000000..f4ac8065 --- /dev/null +++ b/test/alert-display.test.tsx @@ -0,0 +1,293 @@ +// @vitest-environment jsdom +// +// The last hop of the alert path: a verdict is computed (ip-monitor) and now it +// has to be VISIBLE. `renderer-smoke.test.tsx` only proves these components +// mount; nothing asserted that an EXPOSED verdict actually turns the HUD red, +// or that `overlay.flashOnExposed: false` really silences the flash. Since +// RedLog never blocks, a verdict that fails to reach the operator's eye is the +// whole defence failing — so each display option gets an assertion here. +// +// Colours come from lib/hud (jsdom normalises them to rgb()); sizes come from +// the scale/emphasis maths in OverlayApp. + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, cleanup, screen, waitFor, fireEvent } from '@testing-library/react' +import { I18nProvider } from '../src/renderer/src/i18n' +import OverlayApp from '../src/renderer/src/OverlayApp' +import IPStatusCard from '../src/renderer/src/components/IPStatusCard' + +const RED = 'rgb(215, 95, 99)' // HUD.red — exposed +const GREEN = 'rgb(94, 207, 156)' // HUD.green — safe +const CYAN = 'rgb(63, 199, 214)' // HUD.cyan — the resting frame + +const unsub = (): void => {} + +interface Overrides { + ipSafety?: 'safe' | 'exposed' | 'unknown' + externalIP?: string | null + internalIP?: string | null + error?: string | null + overlay?: Record +} + +function installBridge(o: Overrides = {}): void { + const status = { + externalIP: o.externalIP === undefined ? '203.0.113.7' : o.externalIP, + internalIP: o.internalIP === undefined ? '10.0.0.2' : o.internalIP, + ipSafety: o.ipSafety ?? 'safe', + lastCheck: 1_700_000_000_000, + error: o.error ?? null, + settling: false + } + ;(window as unknown as { redlog: unknown }).redlog = { + platform: 'darwin', + ip: { getStatus: async () => status, onStatus: () => unsub }, + config: { get: async () => ({ overlay: o.overlay ?? {} }) }, + recording: { get: async () => true, onChange: () => unsub }, + pivots: { getActive: async () => [], onChange: () => unsub }, + overlay: { + onInteractive: () => unsub, + setExpanded: () => {}, + hide: () => {}, + quickMark: () => {}, + instantMark: async () => ({ ok: true }), + autosize: () => {} + } + } +} + +function renderOverlay(o: Overrides = {}): HTMLElement { + installBridge(o) + const { container } = render() + return container +} + +/** The neon frame div — the element whose background carries the verdict. + * Structure: container ▸ padding wrapper ▸ frame ▸ inset panel. */ +function frameOf(container: HTMLElement): HTMLElement { + return container.firstElementChild?.firstElementChild as HTMLElement +} + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('HUD — the verdict is visible at a glance', () => { + it('EXPOSED turns the frame red and labels it EXPOSED', async () => { + const c = renderOverlay({ ipSafety: 'exposed' }) + expect(await screen.findByText('EXPOSED')).toBeTruthy() + await waitFor(() => expect(frameOf(c).style.background).toBe(RED)) + }) + + it('SAFE keeps the resting cyan frame and labels it SAFE', async () => { + const c = renderOverlay({ ipSafety: 'safe' }) + expect(await screen.findByText('SAFE')).toBeTruthy() + expect(frameOf(c).style.background).toBe(CYAN) + }) + + it('UNKNOWN is its own amber state, not a silent pass', async () => { + renderOverlay({ ipSafety: 'unknown' }) + expect(await screen.findByText('IP?')).toBeTruthy() + }) + + it('shows the external IP that the verdict was computed from', async () => { + renderOverlay({ ipSafety: 'exposed', externalIP: '198.51.100.4' }) + expect(await screen.findAllByText('198.51.100.4')).toHaveLength(1) + }) + + it('renders an em dash rather than a stale address when there is no reading yet', async () => { + renderOverlay({ externalIP: null, internalIP: null }) + await waitFor(() => expect(screen.getAllByText('—').length).toBeGreaterThan(0)) + }) +}) + +describe('overlay.flashOnExposed', () => { + it('default (unset) flashes the frame while EXPOSED', async () => { + const c = renderOverlay({ ipSafety: 'exposed' }) + await waitFor(() => expect(frameOf(c).style.animation).toContain('alarm')) + }) + + it('true flashes', async () => { + const c = renderOverlay({ ipSafety: 'exposed', overlay: { flashOnExposed: true } }) + await waitFor(() => expect(frameOf(c).style.animation).toContain('alarm')) + }) + + it('false keeps the red frame but stops the flashing', async () => { + const c = renderOverlay({ ipSafety: 'exposed', overlay: { flashOnExposed: false } }) + await waitFor(() => expect(frameOf(c).style.background).toBe(RED)) + expect(frameOf(c).style.animation).not.toContain('alarm') + }) + + it('never flashes while SAFE, whatever the setting', async () => { + const c = renderOverlay({ ipSafety: 'safe', overlay: { flashOnExposed: true } }) + await waitFor(() => expect(frameOf(c).style.background).toBe(CYAN)) + expect(frameOf(c).style.animation).not.toContain('alarm') + }) +}) + +describe('overlay.scale + overlay.emphasizeExternalIp', () => { + /** Font size of the compact-bar external IP, in px. */ + async function ipFontSize(o: Overrides): Promise { + renderOverlay(o) + const el = await screen.findByText('203.0.113.7') + return parseFloat((el as HTMLElement).style.fontSize) + } + + it('1.0 (default) renders the external IP at the base 12px', async () => { + expect(await ipFontSize({})).toBe(12) + }) + + it('0.85 / 1.25 / 1.5 scale the type proportionally', async () => { + expect(await ipFontSize({ overlay: { scale: 0.85 } })).toBe(10.2) + cleanup() + expect(await ipFontSize({ overlay: { scale: 1.25 } })).toBe(15) + cleanup() + expect(await ipFontSize({ overlay: { scale: 1.5 } })).toBe(18) + }) + + it('clamps a rogue config to the 0.75–2.0 band', async () => { + expect(await ipFontSize({ overlay: { scale: 99 } })).toBe(24) // 12 × 2 + cleanup() + expect(await ipFontSize({ overlay: { scale: 0.01 } })).toBe(9) // 12 × 0.75 + }) + + it('a zero or negative scale is ignored, not clamped to the floor', async () => { + expect(await ipFontSize({ overlay: { scale: 0 } })).toBe(12) + cleanup() + expect(await ipFontSize({ overlay: { scale: -1 } })).toBe(12) + }) + + it('emphasizeExternalIp bumps ONLY the IP by a further 1.4×', async () => { + expect(await ipFontSize({ overlay: { emphasizeExternalIp: true } })).toBe(16.8) + cleanup() + // compounds with scale + expect(await ipFontSize({ overlay: { emphasizeExternalIp: true, scale: 1.5 } })).toBe(25.2) + }) +}) + +describe('overlay.passThrough / passThroughOpacity', () => { + /** Dimmed chrome: the REC pill. The external IP must never be dimmed. */ + function recOpacity(): string { + return (screen.getByText('REC').parentElement as HTMLElement).style.opacity + } + + it('off by default — nothing is dimmed', async () => { + renderOverlay({}) + await screen.findByText('REC') + expect(recOpacity()).toBe('') + }) + + it('on dims the chrome to the default 0.4', async () => { + renderOverlay({ overlay: { passThrough: true } }) + await waitFor(() => expect(recOpacity()).toBe('0.4')) + }) + + it('honours a custom opacity', async () => { + renderOverlay({ overlay: { passThrough: true, passThroughOpacity: 0.15 } }) + await waitFor(() => expect(recOpacity()).toBe('0.15')) + }) + + it('ignores a 0 opacity — an invisible HUD would be worse than none', async () => { + renderOverlay({ overlay: { passThrough: true, passThroughOpacity: 0 } }) + await waitFor(() => expect(recOpacity()).toBe('0.4')) + }) + + it('leaves the external IP at full opacity — the point of the HUD stays readable', async () => { + renderOverlay({ overlay: { passThrough: true } }) + const ip = await screen.findByText('203.0.113.7') + expect((ip.parentElement?.parentElement as HTMLElement).style.opacity).toBe('') + }) +}) + +describe('overlay.showMarkButton', () => { + async function expand(): Promise { + fireEvent.click(await screen.findByLabelText('Show details')) + } + + it('default (unset) shows both mark buttons in the expanded pane', async () => { + renderOverlay({}) + await expand() + expect(await screen.findByText(/QUICK/)).toBeTruthy() + expect(screen.getByText(/DETAIL/)).toBeTruthy() + }) + + it('false hides them, leaving the keep-open toggle', async () => { + renderOverlay({ overlay: { showMarkButton: false } }) + await expand() + await waitFor(() => expect(screen.getByLabelText(/keep open/i)).toBeTruthy()) + expect(screen.queryByText(/QUICK/)).toBeNull() + expect(screen.queryByText(/DETAIL/)).toBeNull() + }) +}) + +describe('HUD expanded pane', () => { + it('spells the verdict out in words, not just colour', async () => { + renderOverlay({ ipSafety: 'exposed' }) + fireEvent.click(await screen.findByLabelText('Show details')) + expect(await screen.findByText('Exposed IP — Not Protected')).toBeTruthy() + }) + + it('tells an operator how to fix an UNKNOWN verdict', async () => { + renderOverlay({ ipSafety: 'unknown' }) + fireEvent.click(await screen.findByLabelText('Show details')) + expect(await screen.findByText(/Set Safe IPs \/ Exposed IPs in Settings/)).toBeTruthy() + }) + + it('surfaces a provider error instead of pretending the reading is fresh', async () => { + renderOverlay({ error: 'All IP providers failed' }) + fireEvent.click(await screen.findByLabelText('Show details')) + expect(await screen.findByText('All IP providers failed')).toBeTruthy() + }) +}) + +describe('IPStatusCard — the same verdict on the dashboard', () => { + beforeEach(() => installBridge()) + + function renderCard(o: Overrides = {}): HTMLElement { + installBridge(o) + const { container } = render() + return container + } + + it('shows a placeholder until the first reading lands', () => { + const c = renderCard() + expect(c.textContent).toContain('Checking IP') + }) + + it('SAFE: green dot, no warning copy', async () => { + const c = renderCard({ ipSafety: 'safe' }) + expect(await screen.findByText('Safe IP')).toBeTruthy() + // G-C1: emerald-500 and green-500 are the SAME hex in tailwind.config.js's + // soften map; the shared severity scale emits the emerald alias. + expect(c.querySelector('.bg-emerald-500')).toBeTruthy() + expect(c.textContent).not.toContain('EXPOSED') + }) + + it('EXPOSED: red pulsing dot + the "check your VPN" hint', async () => { + const c = renderCard({ ipSafety: 'exposed' }) + expect(await screen.findByText('Exposed IP')).toBeTruthy() + expect(c.querySelector('.bg-red-500.animate-pulse')).toBeTruthy() + expect(c.textContent).toContain('traffic is not going through your VPN/tunnel') + }) + + it('UNKNOWN: yellow dot + the "configure the lists" hint', async () => { + const c = renderCard({ ipSafety: 'unknown' }) + expect(await screen.findByText('Unknown IP')).toBeTruthy() + // Same aliasing: amber-500 and yellow-500 share a hex in the soften map. + expect(c.querySelector('.bg-amber-500')).toBeTruthy() + expect(c.textContent).toContain('Settings ▸ Network') + }) + + it('shows both addresses so an operator can tell egress from LAN', async () => { + renderCard({ externalIP: '198.51.100.4', internalIP: '192.168.1.20' }) + expect(await screen.findByText('198.51.100.4')).toBeTruthy() + expect(screen.getByText('192.168.1.20')).toBeTruthy() + }) + + it('renders the provider error alongside the last known verdict', async () => { + const c = renderCard({ error: 'All IP providers failed' }) + await screen.findByText('Safe IP') + expect(c.textContent).toContain('All IP providers failed') + }) +}) diff --git a/test/alert-severity.test.ts b/test/alert-severity.test.ts new file mode 100644 index 00000000..bd4bab22 --- /dev/null +++ b/test/alert-severity.test.ts @@ -0,0 +1,106 @@ +// The one severity scale (G-C1). Both alarm roles map onto it, so a change here +// changes how BOTH shout — which is the point: they used to drift apart. + +import { describe, it, expect } from 'vitest' +import { + ipSeverity, scopeSeverity, worstSeverity, SEVERITY_CLASS, SEVERITY_HUD, type Severity +} from '../src/renderer/src/lib/alertSeverity' +import { HUD } from '../src/renderer/src/lib/hud' + +describe('ipSeverity — Self alarm onto the scale', () => { + const cases: Array<[string, Severity]> = [ + ['exposed', 'critical'], + ['off_profile', 'warn'], + ['unknown', 'unknown'], + ['safe', 'ok'], + ['presumed_safe', 'ok'] + ] + for (const [verdict, expected] of cases) { + it(`${verdict} → ${expected}`, () => { + expect(ipSeverity(verdict as Parameters[0])).toBe(expected) + }) + } + + // The axes are orthogonal. `presumed_safe` is the GOOD answer, merely an + // inferred one — the inference rides on authority, not on pretending the + // situation is worse than it is. + it('presumed_safe is ok, not warn — authority carries the caveat, not severity', () => { + expect(ipSeverity('presumed_safe')).toBe(ipSeverity('safe')) + }) +}) + +describe('scopeSeverity — Target alarm onto the same scale', () => { + // D1 sits level with `exposed`: both are the thing the operator must not do, + // both observed, both non-silenceable. One is an OPSEC failure, the other an + // authorisation failure. + it('a forbidden target is critical, like an exposed IP', () => { + expect(scopeSeverity('excluded_target')).toBe('critical') + expect(scopeSeverity('excluded_target')).toBe(ipSeverity('exposed')) + }) + + it('a proximity near-miss is warn, like an off-profile IP', () => { + expect(scopeSeverity('adjacent_subnet')).toBe('warn') + expect(scopeSeverity('adjacent_domain')).toBe('warn') + expect(scopeSeverity('adjacent_subnet')).toBe(ipSeverity('off_profile')) + }) + + // The defect G-C1 exists to fix: the scope UI was `count > 0 ? red : green`. + it('D1 and D2 are not the same step', () => { + expect(scopeSeverity('excluded_target')).not.toBe(scopeSeverity('adjacent_domain')) + }) + + // D3 only reaches the list under `alertFloor: 'all'`. Giving it D2's step + // would put the noise G-B3 removed back inside the violation list; giving it + // `ok` would render a recorded departure green. + it('an off-list target gets its own quiet step, distinct from D2', () => { + expect(scopeSeverity('unrelated')).toBe('notice') + expect(scopeSeverity('unrelated')).not.toBe(scopeSeverity('adjacent_subnet')) + expect(scopeSeverity('unrelated')).not.toBe('ok') + }) +}) + +describe('worstSeverity', () => { + it('an empty list is ok', () => expect(worstSeverity([])).toBe('ok')) + it('one observed rule match outranks any number of inferences', () => { + expect(worstSeverity(['warn', 'warn', 'critical', 'warn'])).toBe('critical') + }) + it('orders the whole scale', () => { + expect(worstSeverity(['ok', 'notice'])).toBe('notice') + expect(worstSeverity(['notice', 'unknown'])).toBe('unknown') + expect(worstSeverity(['unknown', 'warn'])).toBe('warn') + expect(worstSeverity(['warn', 'critical'])).toBe('critical') + }) + + // A pile of off-list targets must not out-shout one forbidden-target hit. + it('one critical outranks any number of notices', () => { + expect(worstSeverity(['notice', 'notice', 'notice', 'critical'])).toBe('critical') + }) +}) + +describe('the scale is one scale, not two', () => { + const LEVELS: Severity[] = ['ok', 'notice', 'unknown', 'warn', 'critical'] + + it('every level has both a HUD hex and app classes', () => { + for (const s of LEVELS) { + expect(SEVERITY_HUD[s]).toMatch(/^#[0-9a-f]{6}$/i) + expect(SEVERITY_CLASS[s].dot).toBeTruthy() + expect(SEVERITY_CLASS[s].text).toBeTruthy() + expect(SEVERITY_CLASS[s].border).toBeTruthy() + } + }) + + it('every level is a distinct colour — a scale with a repeat is not a scale', () => { + expect(new Set(LEVELS.map((s) => SEVERITY_HUD[s])).size).toBe(LEVELS.length) + expect(new Set(LEVELS.map((s) => SEVERITY_CLASS[s].dot)).size).toBe(LEVELS.length) + }) + + // The overlay and the app must not drift apart: `hud.ts` says its hexes MUST + // match tailwind's soften map, and the scale is where both are read from. + it('the HUD hexes come from the shared design tokens', () => { + expect(SEVERITY_HUD.ok).toBe(HUD.green) + expect(SEVERITY_HUD.unknown).toBe(HUD.amber) + expect(SEVERITY_HUD.warn).toBe(HUD.orange) + expect(SEVERITY_HUD.critical).toBe(HUD.red) + expect(SEVERITY_HUD.notice).toBe(HUD.muted) + }) +}) diff --git a/test/alert-surfaces.test.tsx b/test/alert-surfaces.test.tsx new file mode 100644 index 00000000..7ace43a3 --- /dev/null +++ b/test/alert-surfaces.test.tsx @@ -0,0 +1,304 @@ +// @vitest-environment jsdom +// +// The two alert surfaces `alert-display` does not cover, plus the live-update +// path that feeds all of them. +// +// StatusBar — the always-visible strip: IP verdict, scope violation count, +// and the recording/capture-health dot. It is the only alert +// surface on screen while the operator is on the Timeline. +// ScopeStatus — the Scope & Evidence view: configured/not-set, the violation +// list, and the "all in scope" green state. +// HUD live — flipping a setting in Settings has to reach the already-open +// overlay window through its IPC subscriptions, not just on the +// next launch. + +import { describe, it, expect, afterEach, vi } from 'vitest' +import { render, cleanup, screen, waitFor, fireEvent } from '@testing-library/react' +import { I18nProvider } from '../src/renderer/src/i18n' +import StatusBar from '../src/renderer/src/components/StatusBar' +import { ScopeStatus } from '../src/renderer/src/components/ScopeStatus' +import OverlayApp from '../src/renderer/src/OverlayApp' + +const unsub = (): void => {} +type Violation = { + target: string + command: string + timestamp: number + reason: 'excluded_target' | 'adjacent_subnet' | 'adjacent_domain' + authority: 'fact' | 'inferred' +} + +interface Opts { + ipSafety?: 'safe' | 'exposed' | 'unknown' + externalIP?: string | null + violations?: Violation[] + scopeConfigured?: boolean + recording?: boolean + captureVerdict?: 'healthy' | 'partial' | 'dark' + lootCount?: number +} + +function installBridge(o: Opts = {}): void { + const violations = o.violations ?? [] + ;(window as unknown as { redlog: unknown }).redlog = { + platform: 'darwin', + project: { active: async () => ({ id: 'p1', name: 'Proj', createdAt: Date.now() - 65_000 }) }, + ip: { + getStatus: async () => ({ + externalIP: o.externalIP === undefined ? '203.0.113.7' : o.externalIP, + internalIP: '10.0.0.2', + ipSafety: o.ipSafety ?? 'safe', + lastCheck: Date.now(), + error: null, + settling: false + }), + onStatus: () => unsub + }, + events: { getCount: async () => 42, onNew: () => unsub }, + loot: { getCount: async () => o.lootCount ?? 0 }, + scope: { + getViolationCount: async () => violations.length, + getViolations: async () => violations, + isConfigured: async () => o.scopeConfigured ?? true + }, + chain: { length: async () => 128 }, + recording: { get: async () => o.recording ?? true, toggle: async () => true, onChange: () => unsub }, + overlay: { isVisible: async () => true, onVisibilityChanged: () => unsub, toggle: () => {} }, + capture: { health: async () => ({ verdict: o.captureVerdict ?? 'healthy' }) }, + quickmarks: { create: async () => ({ id: 'q1' }) }, + data: { exportViolations: async () => '/tmp/violations.json' }, + pivots: { getActive: async () => [], onChange: () => unsub }, + config: { get: async () => ({ overlay: {} }) } + } +} + +function renderWith(ui: React.ReactElement, o: Opts = {}): HTMLElement { + installBridge(o) + return render({ui}).container +} + +afterEach(() => { cleanup(); vi.restoreAllMocks() }) + +describe('StatusBar — the always-visible verdict', () => { + it('SAFE: green dot and the external IP alongside it', async () => { + const c = renderWith(, { ipSafety: 'safe' }) + expect(await screen.findByText('SAFE')).toBeTruthy() + expect(c.querySelector('.bg-emerald-500')).toBeTruthy() + expect(screen.getByText('203.0.113.7')).toBeTruthy() + }) + + it('EXPOSED: red dot and label', async () => { + const c = renderWith(, { ipSafety: 'exposed' }) + expect(await screen.findByText('EXPOSED')).toBeTruthy() + expect(c.querySelector('.bg-red-500')).toBeTruthy() + }) + + it('UNKNOWN: amber, never quietly green', async () => { + const c = renderWith(, { ipSafety: 'unknown' }) + expect(await screen.findByText('IP?')).toBeTruthy() + expect(c.querySelector('.bg-amber-500')).toBeTruthy() + }) + + it('omits the address when there is no reading yet', async () => { + renderWith(, { ipSafety: 'unknown', externalIP: null }) + await screen.findByText('IP?') + expect(screen.queryByText('203.0.113.7')).toBeNull() + }) + + it('scope clean: SCOPE OK', async () => { + renderWith(, { violations: [] }) + expect(await screen.findByText('SCOPE OK')).toBeTruthy() + }) + + it('scope violations: the count is on the strip', async () => { + const v = (n: number): Violation[] => + Array.from({ length: n }, (_, i) => ({ target: `t${i}`, command: 'curl', timestamp: 1 })) + renderWith(, { violations: v(3) }) + expect(await screen.findByText('SCOPE 3')).toBeTruthy() + expect(screen.queryByText('SCOPE OK')).toBeNull() + }) + + it('recording on + healthy capture: pulsing red REC', async () => { + renderWith(, { recording: true, captureVerdict: 'healthy' }) + const btn = await screen.findByTestId('status-bar-recording') + await waitFor(() => expect(btn.dataset.capture).toBe('healthy')) + expect(btn.dataset.recording).toBe('on') + expect(btn.querySelector('.bg-red-500.animate-pulse-slow')).toBeTruthy() + }) + + it('recording on but capture DARK: amber, because "REC" alone would be a lie', async () => { + renderWith(, { recording: true, captureVerdict: 'dark' }) + const btn = await screen.findByTestId('status-bar-recording') + await waitFor(() => expect(btn.dataset.capture).toBe('dark')) + expect(btn.querySelector('.bg-amber-500')).toBeTruthy() + expect(btn.querySelector('.animate-pulse-slow')).toBeNull() + }) + + it('capture PARTIAL: amber and still pulsing', async () => { + renderWith(, { captureVerdict: 'partial' }) + const btn = await screen.findByTestId('status-bar-recording') + await waitFor(() => expect(btn.dataset.capture).toBe('partial')) + expect(btn.querySelector('.bg-amber-500.animate-pulse-slow')).toBeTruthy() + }) + + it('paused: grey PAUSED with a resume hint', async () => { + renderWith(, { recording: false }) + const btn = await screen.findByTestId('status-bar-recording') + await waitFor(() => expect(btn.dataset.recording).toBe('off')) + expect(screen.getByText('PAUSED')).toBeTruthy() + expect(btn.getAttribute('title')).toBe('Click to resume recording') + }) + + it('loot count is highlighted only when there is loot', async () => { + const c = renderWith(, { lootCount: 0 }) + expect(await screen.findByText('0 loot')).toBeTruthy() + expect(c.querySelectorAll('.text-amber-400\\/80')).toHaveLength(0) + cleanup() + renderWith(, { lootCount: 2 }) + expect(await screen.findByText('2 loot')).toBeTruthy() + }) +}) + +describe('ScopeStatus — the Scope & Evidence view', () => { + // G-C1: a violation now carries WHICH rung of the ladder fired, because the + // list stopped being uniformly red — a forbidden-target hit and a proximity + // near-miss are different severities and no longer look alike. + const violation = ( + target: string, + reason: 'excluded_target' | 'adjacent_subnet' | 'adjacent_domain' = 'excluded_target' + ): Violation => ({ + target, + command: `curl ${target}`, + timestamp: 1_700_000_000_000, + reason, + authority: reason === 'excluded_target' ? 'fact' : 'inferred' + }) + + it('no scope set: NOT SET plus the hint that fixes it', async () => { + renderWith(, { scopeConfigured: false, violations: [] }) + expect(await screen.findByText('NOT SET')).toBeTruthy() + expect(screen.getByText('Add scope targets in Settings → Scope tab')).toBeTruthy() + }) + + it('scope set and clean: ACTIVE + all-in-scope, and no export button', async () => { + renderWith(, { scopeConfigured: true, violations: [] }) + expect(await screen.findByText('ACTIVE')).toBeTruthy() + expect(screen.getByText('All commands within scope')).toBeTruthy() + expect(screen.queryByText('Export')).toBeNull() + }) + + it('violations: count, per-row target and command, and the export button', async () => { + renderWith(, { violations: [violation('vpn.example.com'), violation('dc01.app.example.com')] }) + expect(await screen.findByText('2 scope violation(s) detected')).toBeTruthy() + expect(screen.getByText('vpn.example.com')).toBeTruthy() + expect(screen.getByText('curl dc01.app.example.com')).toBeTruthy() + expect(screen.getByText('Export')).toBeTruthy() + expect(screen.queryByText('All commands within scope')).toBeNull() + }) + + it('caps the recent list at 10 rows without losing the count', async () => { + const many = Array.from({ length: 14 }, (_, i) => violation(`host${i}.example.com`)) + const c = renderWith(, { violations: many }) + expect(await screen.findByText('14 scope violation(s) detected')).toBeTruthy() + expect(c.querySelectorAll('.bg-red-900\\/20')).toHaveLength(10) + }) + + // G-C1: the list used to be one red for everything. + it('a proximity near-miss does not render like a forbidden-target hit', async () => { + const c = renderWith(, { + violations: [violation('dc01.example.com'), violation('vpn.example.com', 'adjacent_domain')] + }) + await screen.findByText('dc01.example.com') + expect(c.querySelectorAll('.bg-red-900\\/20')).toHaveLength(1) + expect(c.querySelectorAll('.bg-orange-900\\/15')).toHaveLength(1) + // §3: the inferred one is outlined, not asserted. + expect(c.querySelectorAll('.border-dashed')).toHaveLength(1) + expect(screen.getByText('same domain')).toBeTruthy() + expect(screen.getByText('forbidden target')).toBeTruthy() + }) + + it('shows the chain length so the evidence log is visible next to the violations', async () => { + renderWith(, { violations: [] }) + expect(await screen.findByText('128 entries')).toBeTruthy() + }) +}) + +describe('HUD live-update — a Settings change reaches the open overlay', () => { + /** Bridge whose config subscriptions hand their callbacks back to the test. */ + function installLiveBridge(): Record void> { + installBridge() + const cbs: Record void> = {} + const capture = (name: string) => (cb: (...a: never[]) => void) => { cbs[name] = cb; return unsub } + ;(window as unknown as { redlog: { config: unknown } }).redlog.config = { + get: async () => ({ overlay: { flashOnExposed: true, scale: 1.0, showMarkButton: true } }), + onShowMark: capture('showMark'), + onFlashExposed: capture('flashExposed'), + onScale: capture('scale'), + onEmphasizeIp: capture('emphasizeIp'), + onPassThrough: capture('passThrough') + } + return cbs + } + + async function renderLive(): Promise void>> { + const cbs = installLiveBridge() + render() + await screen.findByText('203.0.113.7') + return cbs + } + + it('subscribes to all five overlay settings', async () => { + const cbs = await renderLive() + expect(Object.keys(cbs).sort()).toEqual(['emphasizeIp', 'flashExposed', 'passThrough', 'scale', 'showMark']) + }) + + it('turning the flash off stops it without a restart', async () => { + const cbs = await renderLive() + installBridge({ ipSafety: 'exposed' }) // not re-read; the HUD is already mounted + cbs.flashExposed(false as never) + await waitFor(() => { + const frame = document.body.querySelector('div > div') as HTMLElement + expect(frame.style.animation).not.toContain('alarm') + }) + }) + + it('a scale change re-renders at the new size', async () => { + const cbs = await renderLive() + cbs.scale(1.5 as never) + await waitFor(() => { + expect(parseFloat((screen.getByText('203.0.113.7') as HTMLElement).style.fontSize)).toBe(18) + }) + }) + + it('emphasis can be switched on live', async () => { + const cbs = await renderLive() + cbs.emphasizeIp(true as never) + await waitFor(() => { + expect(parseFloat((screen.getByText('203.0.113.7') as HTMLElement).style.fontSize)).toBe(16.8) + }) + }) + + it('pass-through arrives with its opacity in one message', async () => { + const cbs = await renderLive() + cbs.passThrough(true as never, 0.2 as never) + await waitFor(() => { + expect((screen.getByText('REC').parentElement as HTMLElement).style.opacity).toBe('0.2') + }) + }) + + it('a 0 opacity in a live message is ignored, same as in the config', async () => { + const cbs = await renderLive() + cbs.passThrough(true as never, 0 as never) + await waitFor(() => { + expect((screen.getByText('REC').parentElement as HTMLElement).style.opacity).toBe('0.4') + }) + }) + + it('hiding the mark buttons takes effect live', async () => { + const cbs = await renderLive() + fireEvent.click(screen.getByLabelText('Show details')) + expect(await screen.findByText(/QUICK/)).toBeTruthy() // present before the change + cbs.showMark(false as never) + await waitFor(() => expect(screen.queryByText(/QUICK/)).toBeNull()) + }) +}) diff --git a/test/api-server.test.ts b/test/api-server.test.ts index 99c00f87..d90d399e 100644 --- a/test/api-server.test.ts +++ b/test/api-server.test.ts @@ -101,4 +101,66 @@ describeDB('api-server', () => { expect(r.status).toBe(200) expect(typeof (await r.json()).count).toBe('number') }) + + // io_ref sidecar (SPEC-IO-SIDECAR.md A1/A2): a posted full body lands in + // /io/.bin, the chained event carries only the digest, + // and the bytes are retrievable — here straight off disk (the io:read IPC is + // the renderer-facing equivalent, covered by io-store unit tests). + it('sidecars a large posted body and keeps only the digest on the event', async () => { + const bigBody = JSON.stringify({ dump: 'Z'.repeat(40 * 1024) }) // > 16 KB preview cap + const post = await fetch(`${base}/api/events`, { + method: 'POST', + headers: { ...authHeaders, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agent_type: 'scanner', + target_id: 'example.test', + data: { + subtype: 'http_response', + url: 'http://example.test/dump', + status: 200, + response_preview: bigBody.slice(0, 16384), + response_body_full: bigBody, + response_body_ct: 'application/json', + }, + }), + }) + expect(post.status).toBe(201) // POST /api/events answers 201 Created + + // Read the event back: full bytes gone, io ref present (A2). + const listed = await fetch(`${base}/api/events?agent_type=scanner&limit=10`, { headers: authHeaders }) + const payload = await listed.json() as unknown + const rows = Array.isArray(payload) ? payload : ((payload as { events?: unknown[] }).events ?? []) + const ev = (rows as Array<{ data: unknown }>) + .map((e) => (typeof e.data === 'string' ? JSON.parse(e.data) : e.data) as Record) + .find((d) => d.subtype === 'http_response' && d.url === 'http://example.test/dump') + expect(ev, 'sidecarred response event').toBeTruthy() + expect(ev!.response_body_full, 'raw bytes must not be chained').toBeUndefined() + const io = ev!.io as { response?: { ref: string; len: number; sha256: string; ct: string } } + expect(io?.response?.ref).toMatch(/^[0-9a-f]{64}$/) + expect(io!.response!.len).toBe(Buffer.byteLength(bigBody, 'utf8')) + expect(io!.response!.ct).toBe('application/json') + + // The bytes really landed in the sidecar and match the digest (A1/A4). + const onDisk = fs.readFileSync(path.join(tmpDir, 'io', `${io!.response!.ref}.bin`)) + expect(onDisk.toString('utf8')).toBe(bigBody) + }) + + it('does not sidecar a small body (preview is already complete)', async () => { + const small = JSON.stringify({ ok: true }) + await fetch(`${base}/api/events`, { + method: 'POST', + headers: { ...authHeaders, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agent_type: 'scanner', + data: { subtype: 'http_response', url: 'http://example.test/small', status: 200, response_preview: small }, + }), + }) + const listed = await fetch(`${base}/api/events?agent_type=scanner&limit=20`, { headers: authHeaders }) + const payload = await listed.json() as unknown + const rows = Array.isArray(payload) ? payload : ((payload as { events?: unknown[] }).events ?? []) + const ev = (rows as Array<{ data: unknown }>) + .map((e) => (typeof e.data === 'string' ? JSON.parse(e.data) : e.data) as Record) + .find((d) => d.url === 'http://example.test/small') + expect(ev!.io).toBeUndefined() + }) }) diff --git a/test/artifact-gc.test.ts b/test/artifact-gc.test.ts new file mode 100644 index 00000000..11b85624 --- /dev/null +++ b/test/artifact-gc.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest' +import { planArtifactRotation, type ArtifactBody } from '../src/core/artifact-gc' + +// Artifact rotation planner (SPEC-SCOPE-AWARE-LIFECYCLE.md Part C). The pure +// decision behind io/ lifecycle: what to compress, what to delete. The tests +// pin the three properties the spec is emphatic about — refcount gating, +// age-or-size triggers, and scope-as-pin eviction order (A5). + +const body = (over: Partial & { sha: string }): ArtifactBody => ({ + bytes: 1000, compressed: false, ageDays: 0, pruneDays: 0, pinned: true, pinScore: 60, ...over +}) + +describe('age prune (refcount-gated)', () => { + it('prunes a body whose newest referencing event is past its window', () => { + const plan = planArtifactRotation([body({ sha: 'a', ageDays: 40, pruneDays: 30 })], { warmDays: 0, maxBytes: 0 }) + expect(plan.toPrune).toEqual(['a']) + }) + + it('keeps a body still referenced by a fresh event (ageDays = newest ref)', () => { + // referenced by an old AND a fresh event → ageDays is the fresh one → keep + const plan = planArtifactRotation([body({ sha: 'a', ageDays: 2, pruneDays: 30 })], { warmDays: 0, maxBytes: 0 }) + expect(plan.toPrune).toEqual([]) + }) + + it('never age-prunes when the window is 0 (keep forever)', () => { + const plan = planArtifactRotation([body({ sha: 'a', ageDays: 9999, pruneDays: 0 })], { warmDays: 0, maxBytes: 0 }) + expect(plan.toPrune).toEqual([]) + }) +}) + +describe('warm compress', () => { + it('compresses survivors older than warmDays that are not already warm', () => { + const plan = planArtifactRotation([ + body({ sha: 'old', ageDays: 10 }), + body({ sha: 'fresh', ageDays: 1 }), + body({ sha: 'already', ageDays: 10, compressed: true }) + ], { warmDays: 7, maxBytes: 0 }) + expect(plan.toCompress).toEqual(['old']) + }) + + it('does not compress a body it is about to prune', () => { + const plan = planArtifactRotation( + [body({ sha: 'a', ageDays: 40, pruneDays: 30 })], + { warmDays: 7, maxBytes: 0 } + ) + expect(plan.toPrune).toEqual(['a']) + expect(plan.toCompress).toEqual([]) + }) +}) + +describe('size prune (scope as pin, A5)', () => { + it('evicts unpinned bodies first when over the cap, keeping pinned', () => { + // cap 2500; four 1000-byte bodies = 4000 over. Two unpinned, two pinned. + const plan = planArtifactRotation([ + body({ sha: 'pin1', pinned: true, pinScore: 60 }), + body({ sha: 'pin2', pinned: true, pinScore: 80 }), + body({ sha: 'unpin-out', pinned: false, pinScore: 10 }), + body({ sha: 'unpin-unknown', pinned: false, pinScore: 30 }) + ], { warmDays: 0, maxBytes: 2500 }) + // must evict 1500 worth → both unpinned (2000), lowest pinScore first + expect(plan.toPrune.sort()).toEqual(['unpin-out', 'unpin-unknown']) + }) + + it('evicts by ascending pin score (out_of_scope before unknown)', () => { + // cap lets exactly one 1000-body survive of the two unpinned → drop the lower score + const plan = planArtifactRotation([ + body({ sha: 'keepPinned', pinned: true, bytes: 1000 }), + body({ sha: 'out', pinned: false, pinScore: 10, bytes: 1000 }), + body({ sha: 'unknown', pinned: false, pinScore: 30, bytes: 1000 }) + ], { warmDays: 0, maxBytes: 2500 }) // 3000 total, cap 2500 → drop one + expect(plan.toPrune).toEqual(['out']) + }) + + it('never size-evicts a pinned body even if that leaves the store over cap', () => { + const plan = planArtifactRotation([ + body({ sha: 'p1', pinned: true, bytes: 5000 }), + body({ sha: 'p2', pinned: true, bytes: 5000 }) + ], { warmDays: 0, maxBytes: 1000 }) + expect(plan.toPrune).toEqual([]) // in-scope evidence kept even under pressure + }) + + it('leaves the store alone when under the cap', () => { + const plan = planArtifactRotation([body({ sha: 'a', bytes: 100, pinned: false, pinScore: 0 })], { warmDays: 0, maxBytes: 10_000 }) + expect(plan.toPrune).toEqual([]) + }) +}) + +describe('combined', () => { + it('age-prunes, compresses, and size-evicts in one pass', () => { + const plan = planArtifactRotation([ + body({ sha: 'expired', ageDays: 99, pruneDays: 30, pinned: false, pinScore: 10 }), + body({ sha: 'warmable', ageDays: 10, pruneDays: 0, pinned: true, pinScore: 60, bytes: 1000 }), + body({ sha: 'evictme', ageDays: 5, pruneDays: 0, pinned: false, pinScore: 10, bytes: 4000 }), + body({ sha: 'pinned', ageDays: 5, pruneDays: 0, pinned: true, pinScore: 80, bytes: 1000 }) + ], { warmDays: 7, maxBytes: 1500 }) + expect(plan.toPrune).toContain('expired') // age + expect(plan.toPrune).toContain('evictme') // size (unpinned) + expect(plan.toPrune).not.toContain('pinned') // pinned survives + expect(plan.toCompress).toEqual(['warmable']) // old survivor compresses + }) +}) diff --git a/test/artifact-pin.test.ts b/test/artifact-pin.test.ts new file mode 100644 index 00000000..82bf335e --- /dev/null +++ b/test/artifact-pin.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest' +import { pinScore, isPinned, PIN_TIER, PIN_THRESHOLD } from '../src/core/artifact-pin' + +// Pin score (SPEC-SCOPE-AWARE-LIFECYCLE.md scope-as-pin). Decides which io +// bodies survive under disk pressure: pinned kept longest, unpinned evicted +// first. The invariant that matters most: evidence (marker/loot) and operator +// pins beat scope, so an out-of-scope body a finding cites is never evicted first. + +const base = { scope: 'unknown' as const, referencedByMarkerOrLoot: false, operatorPinned: false } + +describe('pinScore', () => { + it('ranks by scope verdict: in_scope > unknown > out_of_scope > excluded', () => { + expect(pinScore({ ...base, scope: 'in_scope' })).toBe(PIN_TIER.inScope) + expect(pinScore({ ...base, scope: 'unknown' })).toBe(PIN_TIER.unknown) + expect(pinScore({ ...base, scope: 'out_of_scope' })).toBe(PIN_TIER.outOfScope) + expect(pinScore({ ...base, scope: 'excluded' })).toBe(PIN_TIER.excluded) + // strict ordering + expect(pinScore({ ...base, scope: 'in_scope' })) + .toBeGreaterThan(pinScore({ ...base, scope: 'unknown' })) + expect(pinScore({ ...base, scope: 'out_of_scope' })) + .toBeGreaterThan(pinScore({ ...base, scope: 'excluded' })) + }) + + it('evidence beats scope — a marker/loot ref pins an out-of-scope body', () => { + const outButCited = { scope: 'out_of_scope' as const, referencedByMarkerOrLoot: true, operatorPinned: false } + expect(pinScore(outButCited)).toBe(PIN_TIER.markerLoot) + expect(isPinned(outButCited)).toBe(true) + }) + + it('an explicit operator pin outranks everything, even excluded', () => { + expect(pinScore({ scope: 'excluded', referencedByMarkerOrLoot: false, operatorPinned: true })) + .toBe(PIN_TIER.operator) + }) +}) + +describe('isPinned (eviction gate)', () => { + it('pins in_scope, marker/loot-cited, and operator-pinned bodies', () => { + expect(isPinned({ ...base, scope: 'in_scope' })).toBe(true) + expect(isPinned({ ...base, referencedByMarkerOrLoot: true })).toBe(true) + expect(isPinned({ ...base, operatorPinned: true })).toBe(true) + }) + + it('leaves unknown / out_of_scope / excluded unpinned (evicted first)', () => { + expect(isPinned({ ...base, scope: 'unknown' })).toBe(false) + expect(isPinned({ ...base, scope: 'out_of_scope' })).toBe(false) + expect(isPinned({ ...base, scope: 'excluded' })).toBe(false) + }) + + it('the threshold is the in_scope tier', () => { + expect(PIN_THRESHOLD).toBe(PIN_TIER.inScope) + }) +}) diff --git a/test/authority-stamp.test.ts b/test/authority-stamp.test.ts new file mode 100644 index 00000000..92e49437 --- /dev/null +++ b/test/authority-stamp.test.ts @@ -0,0 +1,62 @@ +// K1: the stamp has to land in the HASHED row, not just in the live UI feed. +// A label saying "this entry is an interpretation" that can be stripped without +// breaking the chain is worthless in an evidence bundle — the same argument +// `_clock_anomaly` already rests on. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import fs from 'fs' +import os from 'os' +import path from 'path' +import { initDB, closeDB } from '../src/core/db' +import { insertEvent, queryEvents } from '../src/core/db/events' + +let dir = '' + +describe('insertEvent stamps §3 authority', () => { + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'redlog-authority-')) + initDB(dir) + }) + afterEach(() => { + closeDB() + fs.rmSync(dir, { recursive: true, force: true }) + }) + + const insert = (agentType: string, data: Record = {}): void => { + insertEvent(agentType, data, { operatorId: 'op-1' }) + } + const dataOf = (agentType: string): Record => + queryEvents({ limit: 100 }).find((e) => e.agentType === agentType)?.data as Record + + it('marks a detector-derived event as inferred', () => { + insert('loot', { subtype: 'loot_found' }) + expect(dataOf('loot').authority).toBe('inferred') + }) + + // Absence means fact. Writing 'fact' on every row would add a field to + // ~99% of the store to say the default out loud. + it('leaves an observed event untouched', () => { + insert('shell', { subtype: 'command_end', command: 'id' }) + expect(dataOf('shell').authority).toBeUndefined() + }) + + it('does not overwrite a label the emitter already set', () => { + insert('system', { subtype: 'scope_violation', reason: 'excluded_target', authority: 'fact' }) + expect(dataOf('system').authority).toBe('fact') + }) + + it('keeps an emitter-set inferred label', () => { + insert('system', { subtype: 'scope_violation', reason: 'adjacent_subnet', authority: 'inferred' }) + expect(dataOf('system').authority).toBe('inferred') + }) + + it('the stamp is inside the hash — stripping it breaks the chain', () => { + insert('loot', { subtype: 'loot_found' }) + const evt = queryEvents({ limit: 10 }).find((e) => e.agentType === 'loot')! + expect(evt.data.authority).toBe('inferred') + // The row's hash was computed over data that includes the label, so a + // verifier recomputing without it cannot reproduce the stored hash. + expect(JSON.stringify(evt.data)).toContain('"authority":"inferred"') + expect(evt.hash).toBeTruthy() + }) +}) diff --git a/test/authority.test.ts b/test/authority.test.ts new file mode 100644 index 00000000..2b23b4f4 --- /dev/null +++ b/test/authority.test.ts @@ -0,0 +1,58 @@ +// The §3 authority primitive (K1 minimal slice). Everything downstream — the +// dashed timeline dot, the deconfliction authority floor, the labelling in an +// exported bundle — reads whatever this resolves, so a wrong answer here is a +// wrong answer everywhere at once. + +import { describe, it, expect } from 'vitest' +import { authorityOf, isInferred } from '../src/core/authority' + +describe('authorityOf — precedence', () => { + it('a per-event label wins over everything', () => { + // scope_violation is the case that forces this: it is `fact` for an + // excluded target and `inferred` for a proximity match, so no type-level + // answer can be right for both. + expect(authorityOf({ agentType: 'system', data: { authority: 'inferred' } })).toBe('inferred') + expect(authorityOf({ agentType: 'loot', data: { authority: 'fact' } })).toBe('fact') + }) + + it('a registered type is consulted next', () => { + const registered = (t: string): 'inferred' | undefined => (t === 'vendor_guess' ? 'inferred' : undefined) + expect(authorityOf({ agentType: 'vendor_guess', data: {} }, registered)).toBe('inferred') + expect(authorityOf({ agentType: 'shell', data: {} }, registered)).toBe('fact') + }) + + it('a per-event label still beats the registration', () => { + const registered = (): 'inferred' => 'inferred' + expect(authorityOf({ agentType: 'anything', data: { authority: 'fact' } }, registered)).toBe('fact') + }) + + it('falls back to the built-in table for detector-derived origins', () => { + for (const agentType of ['loot', 'pivot', 'cleanup', 'file_transfer']) { + expect(authorityOf({ agentType, data: {} })).toBe('inferred') + } + }) + + // Primary capture dominates the stream; defaulting to `inferred` would dash + // nearly everything and drain the distinction of meaning. + it('everything else is a fact', () => { + for (const agentType of ['shell', 'marker', 'agent', 'http', 'screenshot', 'system', 'unheard_of']) { + expect(authorityOf({ agentType, data: {} })).toBe('fact') + } + }) + + it('handles a missing agentType and missing data', () => { + expect(authorityOf({})).toBe('fact') + expect(authorityOf({ agentType: 'loot' })).toBe('inferred') + expect(authorityOf({ agentType: 'loot', data: null })).toBe('inferred') + }) + + it('ignores a junk authority value rather than trusting it', () => { + expect(authorityOf({ agentType: 'shell', data: { authority: 'probably' } })).toBe('fact') + expect(authorityOf({ agentType: 'loot', data: { authority: 42 } })).toBe('inferred') + }) + + it('isInferred is the renderer-facing shorthand', () => { + expect(isInferred({ agentType: 'loot', data: {} })).toBe(true) + expect(isInferred({ agentType: 'shell', data: {} })).toBe(false) + }) +}) diff --git a/test/browser-launcher.test.ts b/test/browser-launcher.test.ts index b634fbe1..43673329 100644 --- a/test/browser-launcher.test.ts +++ b/test/browser-launcher.test.ts @@ -48,4 +48,36 @@ describe('browser launcher args', () => { expect(args).toContain('--foo') expect(args).not.toContain('') }) + + // ignoreCertErrors defaults ON: the whole point of the launcher is routing + // through mitmproxy, whose CA the isolated profile does not trust. + it('accepts the intercepting proxy CA by default', () => { + expect(buildArgs(DEFAULT_BROWSER, PROFILE)).toContain('--ignore-certificate-errors') + }) + + it('leaves cert validation intact when the operator turns it off', () => { + const args = buildArgs({ ...DEFAULT_BROWSER, ignoreCertErrors: false }, PROFILE) + expect(args).not.toContain('--ignore-certificate-errors') + }) + + it('omits the start URL when none is configured', () => { + expect(buildArgs(DEFAULT_BROWSER, PROFILE).some((a) => !a.startsWith('--'))).toBe(false) + }) + + it('keeps the start URL last even with extra args configured', () => { + const args = buildArgs( + { ...DEFAULT_BROWSER, extraArgs: ['--incognito'], startUrl: 'https://example.com' }, + PROFILE + ) + expect(args[args.length - 1]).toBe('https://example.com') + expect(args).toContain('--incognito') + }) + + it('a fully stripped-down config produces no flags at all', () => { + const args = buildArgs( + { binary: '', proxy: '', cdpPort: 0, isolateProfile: false, ignoreCertErrors: false, startUrl: '', extraArgs: [] }, + PROFILE + ) + expect(args).toEqual([]) + }) }) diff --git a/test/bundle-adherence.test.ts b/test/bundle-adherence.test.ts new file mode 100644 index 00000000..a3aaef47 --- /dev/null +++ b/test/bundle-adherence.test.ts @@ -0,0 +1,195 @@ +// G-D1, the bundle half. The loose `data:exportAdherence` file proves nothing on +// its own — anyone can write a JSON file claiming 244 of 247 targets were in +// scope. Inside the bundle the report is a hashed entry in `manifest.files`, so +// it inherits the manifest sha256 and the HMAC and travels signed with the rest +// of the evidence. +// +// The load-bearing invariant is that the report is built from the rows AS +// WRITTEN TO THE BUNDLE, after the layer-4 sanitize swap — otherwise a +// client-deliverable bundle would ship a side channel around its own gate. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import fs from 'fs' +import path from 'path' +import os from 'os' +import crypto from 'crypto' + +let initDB: typeof import('../src/core/db/index').initDB +let closeDB: typeof import('../src/core/db/index').closeDB +let insertEventRaw: typeof import('../src/core/db/events').insertEvent +let exportBundle: typeof import('../src/core/bundle-export').exportBundle + +let dbAvailable = false +try { + const d = await import('../src/core/db/index') + const e = await import('../src/core/db/events') + const b = await import('../src/core/bundle-export') + initDB = d.initDB; closeDB = d.closeDB; insertEventRaw = e.insertEvent; exportBundle = b.exportBundle + dbAvailable = true +} catch { /* better-sqlite3 not built for this Node */ } + +const describeDB = dbAvailable ? describe : describe.skip +let dir = '' + +const SCOPE = { + targets: ['192.168.1.10', '192.168.1.20', '*.app.example.com'], + excludeTargets: ['dc01.app.example.com'] +} + +const hit = (target: string, command = `nmap ${target}`): void => { + insertEventRaw('shell', { subtype: 'command_start', command, detectedTarget: target }, + { engagementId: 'eng', operatorId: 'op', targetId: target }) +} + +const readReport = (outDir: string): Record => + JSON.parse(fs.readFileSync(path.join(outDir, 'scope-adherence.json'), 'utf-8')) + +describeDB('evidence bundle — scope adherence', () => { + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'redlog-bundle-adh-')) + initDB(dir) + }) + afterEach(() => { + closeDB() + fs.rmSync(dir, { recursive: true, force: true }) + }) + + it('ships the report as a hashed file and a manifest headline', () => { + hit('192.168.1.10'); hit('192.168.1.20'); hit('192.168.1.55') + const { outDir, manifest } = exportBundle('eng', { outRoot: dir, scope: SCOPE }) + + const entry = manifest.files.find((f) => f.path === 'scope-adherence.json') + expect(entry).toBeTruthy() + expect(manifest.scopeAdherence?.summary).toBe('3 targets, 2 in scope, 0 excluded, 1 adjacent') + + // The claim in the manifest and the evidence beside it must agree. + const report = readReport(outDir) as unknown as { totals: Record } + expect(report.totals).toEqual(manifest.scopeAdherence?.totals) + }) + + // This is what makes the bundle copy worth more than the loose file. + it('the report is covered by the manifest hash', () => { + hit('192.168.1.10') + const { outDir, manifest } = exportBundle('eng', { outRoot: dir, scope: SCOPE }) + const entry = manifest.files.find((f) => f.path === 'scope-adherence.json')! + const onDisk = crypto.createHash('sha256') + .update(fs.readFileSync(path.join(outDir, 'scope-adherence.json'))).digest('hex') + expect(onDisk).toBe(entry.sha256) + + const manifestSha = crypto.createHash('sha256') + .update(fs.readFileSync(path.join(outDir, 'manifest.json'))).digest('hex') + expect(fs.readFileSync(path.join(outDir, 'manifest.sha256'), 'utf-8').trim()).toBe(manifestSha) + }) + + it('classifies every rung, not just the ones that alerted', () => { + hit('192.168.1.10') // D0 + hit('api.app.example.com') // D0 + hit('dc01.app.example.com') // D1 + hit('192.168.1.55') // D2 subnet + hit('vpn.example.com') // D2 domain + hit('8.8.8.8') // D3 + const { manifest } = exportBundle('eng', { outRoot: dir, scope: SCOPE }) + expect(manifest.scopeAdherence?.totals).toMatchObject({ + targets: 6, in_scope: 2, excluded: 1, adjacent_subnet: 1, adjacent_domain: 1, unrelated: 1 + }) + }) + + // An empty report reads as "nothing was out of bounds", which is a claim the + // bundle has no basis for making when no scope was ever declared. + it('no scope means no file and no claim, not an empty one', () => { + hit('anything.test') + const { outDir, manifest } = exportBundle('eng', { outRoot: dir }) + expect(manifest.scopeAdherence).toBeNull() + expect(fs.existsSync(path.join(outDir, 'scope-adherence.json'))).toBe(false) + expect(manifest.files.some((f) => f.path === 'scope-adherence.json')).toBe(false) + }) + + it('an internal bundle gets the proof too — it stayed inside something as well', () => { + hit('192.168.1.10') + const { manifest } = exportBundle('eng', { outRoot: dir, scope: SCOPE }) + expect(manifest.scopeAdherence?.totals.in_scope).toBe(1) + }) + + it('carries the scope provenance so a reviewer can join it to the issued document', () => { + hit('192.168.1.10') + const prov = { + path: '/engagements/acme/scope.txt', + digest: 'b'.repeat(64), + bytes: 64, + entries: 3, + modifiedAt: 1, + loadedAt: 2 + } + const { manifest } = exportBundle('eng', { outRoot: dir, scope: { ...SCOPE, provenance: prov } }) + expect(manifest.scopeAdherence?.provenance).toEqual({ + path: prov.path, digest: prov.digest, entries: prov.entries + }) + }) + + it('tells the reviewer how to check it', () => { + hit('192.168.1.10') + const { outDir } = exportBundle('eng', { outRoot: dir, scope: SCOPE }) + const readme = fs.readFileSync(path.join(outDir, 'README.md'), 'utf-8') + expect(readme).toContain('scope-adherence.json') + expect(readme).toContain('shasum -a 256') + expect(readme).toContain('denominator') + }) + + it('RedLog\'s own bookkeeping does not inflate the target count', () => { + hit('192.168.1.55') + insertEventRaw('system', { + subtype: 'scope_violation', target: '192.168.1.55', reason: 'adjacent_subnet', authority: 'inferred' + }, { engagementId: 'eng', operatorId: 'op', targetId: '192.168.1.55' }) + const { manifest } = exportBundle('eng', { outRoot: dir, scope: SCOPE }) + expect(manifest.scopeAdherence?.totals.targets).toBe(1) + expect(manifest.scopeAdherence?.totals.actions).toBe(1) + }) + + // THE load-bearing invariant. The report is built from the rows as written to + // the bundle, after the layer-4 sanitize swap — so it is structurally unable + // to carry content the bundle itself does not. Asserted as a property rather + // than against a specific redaction, so it keeps holding when the sanitize + // rules change. + it('every command sample in the report also appears in events.jsonl', () => { + hit('192.168.1.10', 'nmap -sV 192.168.1.10') + hit('192.168.1.55', 'curl -H "Authorization: Bearer sk-live-abc" http://192.168.1.55/admin') + hit('8.8.8.8', 'dig @8.8.8.8 example.com') + const { outDir } = exportBundle('eng', { + outRoot: dir, + profile: 'client-deliverable', + scope: SCOPE, + operatorId: 'op' + }) + // Every command the bundle actually ships, decoded. (events.jsonl rows carry + // `data` as a JSON STRING, so the text is double-escaped in the file — + // comparing raw text would test the escaping, not the invariant.) + const shipped = new Set() + for (const line of fs.readFileSync(path.join(outDir, 'events.jsonl'), 'utf-8').split('\n')) { + if (!line.trim()) continue + const row = JSON.parse(line) as { data?: string } + if (typeof row.data !== 'string') continue + const d = JSON.parse(row.data) as { command?: unknown } + if (typeof d.command === 'string') shipped.add(d.command) + } + + const report = readReport(outDir) as unknown as { + targets: Array<{ target: string; commands: string[] }> + } + expect(report.targets.length).toBeGreaterThan(0) + const samples = report.targets.flatMap((r) => r.commands) + expect(samples.length).toBeGreaterThan(0) + for (const cmd of samples) { + // Samples are sliced to 200 chars, so a prefix match is the honest test. + expect([...shipped].some((s) => s.startsWith(cmd))).toBe(true) + } + }) + + it('a client-deliverable bundle still states the denominator', () => { + hit('192.168.1.10'); hit('192.168.1.55') + const { manifest } = exportBundle('eng', { + outRoot: dir, profile: 'client-deliverable', scope: SCOPE, operatorId: 'op' + }) + expect(manifest.scopeAdherence?.totals.targets).toBe(2) + expect(manifest.scopeAdherence?.totals.in_scope).toBe(1) + }) +}) diff --git a/test/bundle-export.test.ts b/test/bundle-export.test.ts index ba690cf0..057cbd49 100644 --- a/test/bundle-export.test.ts +++ b/test/bundle-export.test.ts @@ -104,6 +104,20 @@ describeDB('evidence bundle export', () => { expect(fs.existsSync(path.join(outDir, 'agent-transcripts', 'claude-1.jsonl'))).toBe(true) }) + it('copies io_ref sidecar bodies into the bundle, hashed into the manifest', () => { + // io/.bin — the body a scanner event references by digest only. + const body = '{"leaked":"' + 'Z'.repeat(40 * 1024) + '"}' + const sha = crypto.createHash('sha256').update(Buffer.from(body, 'utf8')).digest('hex') + seedFile('io', `${sha}.bin`, body) + ins('scanner', { subtype: 'http_response', url: 'http://x/y', status: 200, io: { response: { ref: sha, len: body.length, sha256: sha } } }) + const { outDir, manifest } = exportBundle('eng') + const copied = path.join(outDir, 'io', `${sha}.bin`) + expect(fs.existsSync(copied), 'sidecar body copied into bundle').toBe(true) + const entry = manifest.files.find((f) => f.path === `io/${sha}.bin`) + expect(entry, 'io body listed in manifest').toBeTruthy() + expect(entry!.sha256).toBe(sha) // file name IS its digest — content-addressed + }) + it('never exports operator token hashes', () => { const { outDir } = exportBundle('eng') const opsPath = path.join(outDir, 'operators.json') diff --git a/test/c2-tailers.test.ts b/test/c2-tailers.test.ts new file mode 100644 index 00000000..9cb09636 --- /dev/null +++ b/test/c2-tailers.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest' +import { createRequire } from 'module' + +// c2-tailers bundled pack (SPEC-AI-ERA-PLUGINS Gap 2). The parser ships as a +// dependency-free CommonJS module; required directly to pin its output shape. +const require_ = createRequire(import.meta.url) +const { parseC2Line, parseC2Log } = require_('../plugins/c2-tailers/parse.js') + +describe('generic C2 contract', () => { + it('maps a check-in to scanner.c2_checkin with the host as target', () => { + const ev = parseC2Line('{"kind":"checkin","framework":"mythic","session":"b-01","host":"10.0.0.5","os":"linux","user":"root","is_beacon":true}', 'generic') + expect(ev).toMatchObject({ agent_type: 'scanner', target_id: '10.0.0.5' }) + expect(ev.data).toMatchObject({ subtype: 'c2_checkin', framework: 'mythic', session: 'b-01', os: 'linux', user: 'root', is_beacon: true }) + }) + + it('maps a task to scanner.c2_task', () => { + const ev = parseC2Line('{"kind":"task","session":"b-01","host":"10.0.0.5","command":"whoami","output_len":12}', 'generic') + expect(ev.data).toMatchObject({ subtype: 'c2_task', command: 'whoami', output_len: 12 }) + }) + + it('maps a pivot to a pivot event aligned with the built-in shape', () => { + const ev = parseC2Line('{"kind":"pivot","framework":"sliver","via":"10.0.0.5","route":"10.1.0.0/16"}', 'generic') + expect(ev).toMatchObject({ agent_type: 'pivot', target_id: '10.0.0.5' }) + expect(ev.data).toMatchObject({ subtype: 'open', tool: 'sliver', via: '10.0.0.5', route: '10.1.0.0/16' }) + }) + + it('closed pivot → subtype closed', () => { + const ev = parseC2Line('{"kind":"pivot","via":"h","closed":true}', 'generic') + expect(ev.data.subtype).toBe('closed') + }) + + it('skips unknown kinds and junk', () => { + expect(parseC2Line('{"kind":"heartbeat"}', 'generic')).toBeNull() + expect(parseC2Line('not json', 'generic')).toBeNull() + expect(parseC2Line('', 'generic')).toBeNull() + }) +}) + +describe('sliver mapping (best-effort)', () => { + it('maps a Sliver session object to c2_checkin', () => { + const line = '{"ID":"a1b2","Name":"CRAZY_HORSE","Hostname":"dc01","Username":"NT/SYSTEM","OS":"windows","RemoteAddress":"10.0.0.9:4444","IsBeacon":true}' + const ev = parseC2Line(line, 'sliver') + expect(ev).toMatchObject({ agent_type: 'scanner', target_id: 'dc01' }) + expect(ev.data).toMatchObject({ subtype: 'c2_checkin', framework: 'sliver', session: 'CRAZY_HORSE', os: 'windows', user: 'NT/SYSTEM', is_beacon: true }) + }) + + it('skips objects with no identifying fields', () => { + expect(parseC2Line('{"level":"debug","msg":"tick"}', 'sliver')).toBeNull() + }) +}) + +describe('parseC2Log', () => { + it('parses a JSONL blob, skipping blank/unparseable lines', () => { + const blob = [ + '{"kind":"checkin","host":"h1"}', + '', + 'garbage', + '{"kind":"task","host":"h1","command":"id"}' + ].join('\n') + expect(parseC2Log(blob, 'generic')).toHaveLength(2) + }) +}) diff --git a/test/clipboard-options.test.ts b/test/clipboard-options.test.ts new file mode 100644 index 00000000..411842cb --- /dev/null +++ b/test/clipboard-options.test.ts @@ -0,0 +1,196 @@ +// clipboard.{enabled,pollMs,storePreview} — the three most privacy-sensitive +// switches in the config. `storePreview` in particular decides whether any +// clipboard text at all reaches disk, so "off means off" needs an assertion, +// not a code comment. + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' + +let clipboardText = '' +const inserted: Array<{ type: string; data: Record }> = [] + +vi.mock('electron', () => ({ clipboard: { readText: () => clipboardText } })) + +vi.mock('../src/core/db/events', () => ({ + insertEvent: (type: string, data: Record) => { + inserted.push({ type, data }) + return { id: `e${inserted.length}`, agentType: type, data } + } +})) + +const { configureClipboardMonitor, stopClipboardMonitor } = await import('../src/main/clipboard-monitor') +const { eventBus } = await import('../src/core/event-bus') + +/** Set the clipboard, let one poll fire, and return any event it produced. */ +function poll(text: string): Record | undefined { + clipboardText = text + vi.advanceTimersByTime(5000) + return inserted[inserted.length - 1]?.data +} + +beforeEach(() => { + vi.useFakeTimers() + inserted.length = 0 + clipboardText = '' + if (eventBus.paused) eventBus.resume() +}) + +afterEach(() => { + stopClipboardMonitor() + vi.useRealTimers() +}) + +describe('clipboard.enabled', () => { + it('off (the default) polls nothing, whatever lands on the clipboard', () => { + configureClipboardMonitor({ enabled: false, engagementId: 'e', operatorId: 'op' }) + poll('sk-live-abcdefghijklmnop') + expect(inserted).toHaveLength(0) + }) + + it('on captures a clipboard change', () => { + configureClipboardMonitor({ enabled: true, engagementId: 'e', operatorId: 'op' }) + const data = poll('hello world') + expect(data?.subtype).toBe('clipboard_changed') + }) + + it('flipping it back off stops the polling', () => { + configureClipboardMonitor({ enabled: true, engagementId: 'e', operatorId: 'op' }) + poll('first') + const seen = inserted.length + configureClipboardMonitor({ enabled: false }) + poll('second') + expect(inserted).toHaveLength(seen) + }) + + it('seeds from whatever was already on the clipboard, so opening RedLog does not capture it', () => { + clipboardText = 'a password from before this session' + configureClipboardMonitor({ enabled: true, engagementId: 'e', operatorId: 'op' }) + vi.advanceTimersByTime(5000) + expect(inserted).toHaveLength(0) + }) +}) + +describe('clipboard.pollMs', () => { + // `configureClipboardMonitor` merges a partial over the module's current + // config, so omitting the key (rather than passing undefined) is what "leave + // it alone" looks like. The first case below therefore reads the module + // default — keep it first, since the clamp cases after it overwrite the value. + function intervalFor(opts: { pollMs?: number } = {}): number { + const spy = vi.spyOn(globalThis, 'setInterval') + configureClipboardMonitor({ enabled: true, ...opts, engagementId: 'e', operatorId: 'op' }) + const ms = spy.mock.calls[spy.mock.calls.length - 1][1] as number + spy.mockRestore() + return ms + } + + it('defaults to 1500ms', () => { + expect(intervalFor()).toBe(1500) + }) + + it('honours a slower cadence', () => { + expect(intervalFor({ pollMs: 5000 })).toBe(5000) + }) + + it('clamps anything under 500ms — a tighter loop is pure CPU burn', () => { + expect(intervalFor({ pollMs: 50 })).toBe(500) + expect(intervalFor({ pollMs: 0 })).toBe(500) + }) +}) + +describe('clipboard.storePreview', () => { + beforeEach(() => { clipboardText = '' }) + + it('off: the event carries hash, length and line count but no text', () => { + configureClipboardMonitor({ enabled: true, storePreview: false, engagementId: 'e', operatorId: 'op' }) + const data = poll('super secret note\nsecond line') + expect(data?.preview).toBeNull() + expect(data?.sha256).toMatch(/^[0-9a-f]{64}$/) + expect(data?.length).toBe(29) + expect(data?.lines).toBe(2) + }) + + it('on: a preview is stored', () => { + configureClipboardMonitor({ enabled: true, storePreview: true, engagementId: 'e', operatorId: 'op' }) + const data = poll('an ordinary note') + expect(data?.preview).toBe('an ordinary note') + }) + + it('on: the preview is capped at 120 chars', () => { + configureClipboardMonitor({ enabled: true, storePreview: true, engagementId: 'e', operatorId: 'op' }) + const data = poll('x'.repeat(500)) + expect((data?.preview as string).length).toBe(120) + expect(data?.length).toBe(500) // the true length is still recorded + }) + + it('on: a high-entropy secret inside the preview is masked, never stored raw', () => { + configureClipboardMonitor({ enabled: true, storePreview: true, engagementId: 'e', operatorId: 'op' }) + const secret = 'AKIA4NNQ7XZL2M8VYTBR9WJC5FDK' + const data = poll(`aws key ${secret}`) + expect(data?.preview).not.toContain(secret) + expect(data?.preview).toContain('•') + expect(data?.redactionsInPreview).toBeGreaterThan(0) + }) + + it('the raw text never appears in the event, whatever the setting', () => { + configureClipboardMonitor({ enabled: true, storePreview: false, engagementId: 'e', operatorId: 'op' }) + const secret = 'AKIA4NNQ7XZL2M8VYTBR9WJC5FDK' + poll(`aws key ${secret}`) + expect(JSON.stringify(inserted)).not.toContain(secret) + }) +}) + +describe('clipboard capture gating', () => { + it('dedupes a repeated read of the same value into one event', () => { + configureClipboardMonitor({ enabled: true, engagementId: 'e', operatorId: 'op' }) + poll('same') + poll('same') + poll('same') + expect(inserted).toHaveLength(1) + }) + + it('a changed value after a repeat is captured', () => { + configureClipboardMonitor({ enabled: true, engagementId: 'e', operatorId: 'op' }) + poll('one') + poll('one') + poll('two') + expect(inserted).toHaveLength(2) + }) + + it('an empty clipboard produces nothing', () => { + configureClipboardMonitor({ enabled: true, engagementId: 'e', operatorId: 'op' }) + poll('') + expect(inserted).toHaveLength(0) + }) + + it('paused recording suspends ambient clipboard capture', () => { + configureClipboardMonitor({ enabled: true, engagementId: 'e', operatorId: 'op' }) + eventBus.pause('ui') + poll('captured while paused?') + expect(inserted).toHaveLength(0) + eventBus.resume('ui') + poll('captured after resume') + expect(inserted).toHaveLength(1) + }) + + it('records the loot types found without copying the credential onto the event', () => { + const secret = 'AKIA4NNQ7XZL2M8VYTBR9WJC5FDK' + configureClipboardMonitor({ + enabled: true, + engagementId: 'e', + operatorId: 'op', + lootDetector: { scan: () => [{ type: 'aws_key' }] } as never + }) + const data = poll(secret) + expect(data?.lootTypes).toEqual(['aws_key']) + expect(JSON.stringify(data)).not.toContain(secret) + }) + + it('a throwing loot detector does not lose the clipboard event', () => { + configureClipboardMonitor({ + enabled: true, + engagementId: 'e', + operatorId: 'op', + lootDetector: { scan: () => { throw new Error('boom') } } as never + }) + expect(poll('still recorded')?.subtype).toBe('clipboard_changed') + }) +}) diff --git a/test/config-options.test.ts b/test/config-options.test.ts new file mode 100644 index 00000000..922a7cd7 --- /dev/null +++ b/test/config-options.test.ts @@ -0,0 +1,430 @@ +// Every config block, field by field: the shipped default, what a partial file +// merges into, and what junk values do. +// +// `config.test.ts` covers the load/save/migrate narrative. This file is the +// exhaustive per-option table — a new field with a default that nobody wired up +// (or one whose default silently changes) shows up here rather than in a bug +// report from an engagement. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import fs from 'fs' +import path from 'path' +import os from 'os' +import { loadConfig, saveConfig, loadScopeFile, burpHostToTarget, DEFAULT_VPN_ADAPTERS } from '../src/core/config' +import { classifyTarget } from '../src/core/scope-monitor' + +let tmpDir: string + +beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'redlog-cfg-')) }) +afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) }) + +function write(yaml: string): void { + fs.writeFileSync(path.join(tmpDir, 'config.yaml'), yaml) +} + +// dot-path → expected default. Keeping it as a table means adding a config +// field is a one-line change here, and forgetting to add one is visible — +// enforced by the guard test at the bottom of this file, not by memory. +const DEFAULTS: Array<[string, unknown]> = [ + ['engagement.id', 'default'], + ['engagement.name', 'Default Engagement'], + ['operator.id', 'operator-1'], + ['operator.name', 'Operator'], + + ['network.whitelist', []], + ['network.blacklist', []], + ['network.lanProfile', []], + ['network.checkInterval', 60], + ['network.providers', []], + ['network.confirmations', 3], + ['network.ipMode', 'auto'], + ['network.staleAfter', 2], + ['network.showWifiName', false], + + ['scope.alertFloor', 'adjacent'], + ['scope.targets', []], + ['scope.excludeTargets', []], + ['scope.proximityBits', 24], + ['scope.publicSuffixes', []], + ['scope.scopeFile', null], + + ['screenshot.quality', 85], + ['screenshot.intervalSec', 0], + + ['overlay.showMarkButton', true], + ['overlay.showInDock', true], + ['overlay.flashOnExposed', true], + ['overlay.scale', 1.0], + ['overlay.emphasizeExternalIp', false], + ['overlay.passThrough', false], + ['overlay.passThroughOpacity', 0.4], + + ['terminal.maxCastBytes', 52428800], // 50 MB + ['terminal.castKeepDays', 0], // 0 = keep forever + ['screenshots.keepDays', 0], + ['io.keepDays', 0], + ['io.warmDays', 0], + ['io.maxBytes', 0], + + ['clipboard.enabled', false], // opt-in: clipboards hold secrets + ['clipboard.pollMs', 1500], + ['clipboard.storePreview', false], + + ['browser.binary', ''], // '' = auto-detect + ['browser.proxy', 'http://127.0.0.1:8080'], + ['browser.cdpPort', 9222], + ['browser.isolateProfile', true], + ['browser.ignoreCertErrors', true], + ['browser.startUrl', ''], + ['browser.extraArgs', []], + + ['redaction.allowlist', []], + ['redaction.denylist', []], + ['redaction.entropyThreshold', 4.5], + ['redaction.minLength', 20], + + ['deconfliction.enabled', false], + ['deconfliction.url', ''], + ['deconfliction.secret', ''], + ['deconfliction.events', ['marker', 'system', 'credential_use', 'c2_checkin']], + ['deconfliction.subtypes', ['scope_violation']], + ['deconfliction.includeData', false], + ['deconfliction.authorityFloor', 'inferred'], + + ['cloudShare.endpoint', ''], + ['cloudShare.authToken', ''], + + ['fileWatcher.enabled', false], + ['fileWatcher.watchPaths', []], + ['fileWatcher.ignorePatterns', []], + + ['processMonitor.enabled', false], + ['processMonitor.pollMs', 500], + ['processMonitor.ignoreCommands', []], + + ['agentTailer.enabled', true], // on by default: AI audit coverage + ['agentTailer.emitThinking', false] +] + +describe('shipped defaults — one assertion per option', () => { + function at(obj: unknown, dotted: string): unknown { + return dotted.split('.').reduce((o, k) => (o as Record)?.[k], obj) + } + + for (const [dotted, expected] of DEFAULTS) { + it(`${dotted} = ${JSON.stringify(expected)}`, () => { + expect(at(loadConfig(tmpDir), dotted)).toEqual(expected) + }) + } + + it('marketplace.defaultRegistryUrl points at the bundled example registry', () => { + expect(loadConfig(tmpDir).marketplace.defaultRegistryUrl).toMatch(/^https:\/\/raw\.githubusercontent\.com\/.+index\.json$/) + }) + + it('ships the full VPN adapter list, all enabled', () => { + const adapters = loadConfig(tmpDir).network.vpnAdapters + expect(adapters).toEqual(DEFAULT_VPN_ADAPTERS) + expect(adapters.every((a) => a.enabled)).toBe(true) + expect(adapters.map((a) => a.name)).toContain('WireGuard') + }) + + it('every shipped VPN adapter pattern is a valid regex', () => { + for (const a of DEFAULT_VPN_ADAPTERS) { + expect(() => new RegExp(a.pattern, 'i')).not.toThrow() + } + }) + + it('a corrupt config.yaml falls back to defaults rather than crashing the app', () => { + write('network:\n whitelist: [unclosed\n') + expect(loadConfig(tmpDir).network.checkInterval).toBe(60) + }) + + it('an empty config.yaml falls back to defaults', () => { + write('') + expect(loadConfig(tmpDir).engagement.id).toBe('default') + }) +}) + +describe('merge semantics — what a partial file does to the rest', () => { + it('a nested override leaves its siblings at the default', () => { + write('network:\n checkInterval: 5\n') + const c = loadConfig(tmpDir) + expect(c.network.checkInterval).toBe(5) + expect(c.network.confirmations).toBe(3) + expect(c.network.ipMode).toBe('auto') + }) + + it('arrays REPLACE, they do not concatenate', () => { + write('deconfliction:\n events:\n - marker\n') + expect(loadConfig(tmpDir).deconfliction.events).toEqual(['marker']) + }) + + it('an explicitly empty array wins over the default list', () => { + write('deconfliction:\n subtypes: []\n') + expect(loadConfig(tmpDir).deconfliction.subtypes).toEqual([]) + }) + + it('an explicit false is kept — not treated as "unset, use the default"', () => { + write('scope:\n alertFloor: excluded_only\nagentTailer:\n enabled: false\n') + const c = loadConfig(tmpDir) + expect(c.scope.alertFloor).toBe('excluded_only') + expect(c.agentTailer?.enabled).toBe(false) + }) + + it('an explicit 0 is kept — 0 is meaningful for every *Days / *Bytes field', () => { + write('io:\n keepDays: 0\n warmDays: 7\n') + const c = loadConfig(tmpDir) + expect(c.io?.keepDays).toBe(0) + expect(c.io?.warmDays).toBe(7) + }) + + it('an unknown key is carried through rather than dropped', () => { + write('network:\n futureOption: 42\n') + const net = loadConfig(tmpDir).network as unknown as Record + expect(net.futureOption).toBe(42) + }) + + it('round-trips every block through save → load unchanged', () => { + const c = loadConfig(tmpDir) + c.network.whitelist = ['10.8.0.0/24'] + c.network.blacklist = ['1.2.3.4'] + c.network.ipMode = 'dns' + c.overlay.scale = 1.25 + c.io = { keepDays: 30, warmDays: 7, maxBytes: 1024 } + saveConfig(tmpDir, c) + const back = loadConfig(tmpDir) + expect(back.network.whitelist).toEqual(['10.8.0.0/24']) + expect(back.network.ipMode).toBe('dns') + expect(back.overlay.scale).toBe(1.25) + expect(back.io).toEqual({ keepDays: 30, warmDays: 7, maxBytes: 1024 }) + }) + + it('saveConfig creates the project directory when it does not exist yet', () => { + const nested = path.join(tmpDir, 'a', 'b') + saveConfig(nested, loadConfig(tmpDir)) + expect(fs.existsSync(path.join(nested, 'config.yaml'))).toBe(true) + }) +}) + +describe('legacy field migration', () => { + const cases: Array<[string, string, 'whitelist' | 'blacklist', string[]]> = [ + ['vpnIPs → whitelist', 'network:\n vpnIPs: [10.8.0.0/24]\n', 'whitelist', ['10.8.0.0/24']], + ['safeIPs → whitelist', 'network:\n safeIPs: [10.8.0.0/24]\n', 'whitelist', ['10.8.0.0/24']], + ['dailyIPs → blacklist', 'network:\n dailyIPs: [1.2.3.4]\n', 'blacklist', ['1.2.3.4']], + ['exposedIPs → blacklist', 'network:\n exposedIPs: [1.2.3.4]\n', 'blacklist', ['1.2.3.4']] + ] + for (const [name, yaml, field, expected] of cases) { + it(name, () => { + write(yaml) + expect(loadConfig(tmpDir).network[field]).toEqual(expected) + }) + } + + it('the newest name wins when several generations are present', () => { + write('network:\n whitelist: [10.0.0.0/8]\n safeIPs: [172.16.0.0/12]\n vpnIPs: [192.168.0.0/16]\n') + expect(loadConfig(tmpDir).network.whitelist).toEqual(['10.0.0.0/8']) + }) + + it('safeIPs beats vpnIPs when both legacy names are present', () => { + write('network:\n safeIPs: [172.16.0.0/12]\n vpnIPs: [192.168.0.0/16]\n') + expect(loadConfig(tmpDir).network.whitelist).toEqual(['172.16.0.0/12']) + }) + + it('drops the legacy key so it cannot re-migrate on the next save', () => { + write('network:\n vpnIPs: [10.8.0.0/24]\n') + const net = loadConfig(tmpDir).network as unknown as Record + expect(net.vpnIPs).toBeUndefined() + }) + + // G-C3 turned this into a TWO-HOP chain: enforcement → warnOnViolation → + // alertFloor. Both hops still run on load, so a config file written before + // either rename lands on the right floor without the operator touching it. + const floor = (): string => loadConfig(tmpDir).scope.alertFloor + + it('enforcement: warn → alerts on near-misses', () => { + write('scope:\n enforcement: warn\n') + expect(floor()).toBe('adjacent') + }) + + it('enforcement: log → the quietest floor (the old mode did nothing)', () => { + write('scope:\n enforcement: log\n') + expect(floor()).toBe('excluded_only') + }) + + // `excluded_only`, not a "none": `warnOnViolation: false` never silenced D1 + // either — excluded targets always raised regardless — so the two-value + // control was always this floor under another name. + it('the quietest floor still alerts on explicitly excluded targets', () => { + write('scope:\n enforcement: log\n') + expect(floor()).not.toBe('none') + expect(['excluded_only', 'adjacent', 'all']).toContain(floor()) + }) + + it('enforcement: block (removed) → alerts ON — the strictest value cannot land on silence', () => { + write('scope:\n enforcement: block\n') + expect(floor()).toBe('adjacent') + }) + + it('an unrecognised enforcement value migrates to alerts ON, not off', () => { + write('scope:\n enforcement: nonsense\n') + expect(floor()).toBe('adjacent') + }) + + it('only the literal "log" — the one value that meant quiet — migrates to the quiet floor', () => { + write('scope:\n enforcement: LOG\n') + expect(floor()).toBe('adjacent') + }) + + it('an explicit warnOnViolation is not overwritten by a stale enforcement key', () => { + write('scope:\n enforcement: log\n warnOnViolation: true\n') + expect(floor()).toBe('adjacent') + }) + + it('warnOnViolation: true → adjacent, false → excluded_only', () => { + write('scope:\n warnOnViolation: true\n') + expect(floor()).toBe('adjacent') + write('scope:\n warnOnViolation: false\n') + expect(floor()).toBe('excluded_only') + }) + + it('an explicit alertFloor wins over both legacy keys', () => { + write('scope:\n enforcement: log\n warnOnViolation: false\n alertFloor: all\n') + expect(floor()).toBe('all') + }) +}) + +describe('scope.scopeFile — external scope documents', () => { + function scopeFile(name: string, body: string): string { + const p = path.join(tmpDir, name) + fs.writeFileSync(p, body) + return p + } + + it('plain text: one target per line, # comments and blanks dropped', () => { + const p = scopeFile('scope.txt', '# engagement scope\n\n10.0.0.0/8\n *.example.com \n') + expect(loadScopeFile(p)).toEqual(['10.0.0.0/8', '*.example.com']) + }) + + it('a file with no extension is read as plain text', () => { + const p = scopeFile('scope', 'target.example.com\n') + expect(loadScopeFile(p)).toEqual(['target.example.com']) + }) + + it('JSON array of strings', () => { + const p = scopeFile('scope.json', JSON.stringify(['10.0.0.1', 'target.com', 42])) + expect(loadScopeFile(p)).toEqual(['10.0.0.1', 'target.com']) + }) + + it('Burp/ZAP target-scope JSON: decodes each host shape and skips entries without one', () => { + const p = scopeFile('burp.json', JSON.stringify({ + target: { scope: [{ host: '\\Qexample.com\\E' }, { host: '.*.example.com' }, {}] } + })) + expect(loadScopeFile(p)).toEqual(['example.com', '*.example.com']) + }) + + it('JSON that is neither shape yields an empty list, not a crash', () => { + const p = scopeFile('other.json', JSON.stringify({ unrelated: true })) + expect(loadScopeFile(p)).toEqual([]) + }) + + it('malformed JSON yields an empty list', () => { + const p = scopeFile('bad.json', '{ not json') + expect(loadScopeFile(p)).toEqual([]) + }) + + it('a missing file yields an empty list — an unreadable scope must not throw', () => { + expect(loadScopeFile(path.join(tmpDir, 'nope.txt'))).toEqual([]) + }) +}) + +// G-CFG2: Burp holds a scope host as a REGEX. Only a `\Q` at position 0 used to +// be stripped, so Burp's own "and subdomains" export landed as `*\Qcorp.example.com` +// and matched nothing — the scope silently lost the hosts the engagement was about. +describe('burpHostToTarget — the shapes Burp and ZAP actually write', () => { + const cases: Array<[string, string, string]> = [ + ['literal-quoted host', '\\Qexample.com\\E', 'example.com'], + ['anchored, dot-escaped host', '^example\\.com$', 'example.com'], + ['anchored + literal-quoted', '^\\Qexample.com\\E$', 'example.com'], + ['and-subdomains, dot-escaped', '.*\\.example\\.com$', '*.example.com'], + ['and-subdomains, literal-quoted', '.*\\Qcorp.example.com\\E', '*.corp.example.com'], + ['and-subdomains, unescaped dot', '.*.example.com', '*.example.com'], + ['and-subdomains with no joining dot', '.*example.com', '*.example.com'], + ['deep host', '^\\Qapi.staging.example.com\\E$', 'api.staging.example.com'], + ['bare hostname (already RedLog syntax)', 'example.com', 'example.com'], + ['bare IP', '10.0.0.1', '10.0.0.1'], + ['surrounding whitespace', ' \\Qexample.com\\E ', 'example.com'], + ['several quoted runs', '\\Qapi.\\E\\Qexample.com\\E', 'api.example.com'], + ['unterminated \\Q run', '\\Qexample.com', 'example.com'] + ] + + for (const [name, host, expected] of cases) { + it(`${name}: ${host} → ${expected}`, () => { + expect(burpHostToTarget(host)).toBe(expected) + }) + } + + // Hand-written patterns are handed back untouched: they match nothing, but a + // scope target that VANISHES is the failure with no symptom — the operator + // sees "scope loaded" and never learns what is missing from it. + const passthrough = ['(dev|prod)\\.example\\.com', 'example\\.(com|net)', 'host[0-9]+\\.example\\.com', '.*'] + for (const host of passthrough) { + it(`keeps an undecodable pattern visible rather than dropping it: ${host}`, () => { + expect(burpHostToTarget(host)).toBe(host) + }) + } + + it('a glob already in RedLog syntax survives unchanged', () => { + expect(burpHostToTarget('*.example.com')).toBe('*.example.com') + }) + + it('the decoded target is what the scope classifier actually matches on', () => { + // The point of the fix: the result has to work in classifyTarget, not just + // look right. `*.corp.example.com` covers its anchor host and any child. + const decoded = burpHostToTarget('.*\\Qcorp.example.com\\E') + expect(classifyTarget('www.corp.example.com', { targets: [decoded], excludeTargets: [] })).toBe('in_scope') + expect(classifyTarget('corp.example.com', { targets: [decoded], excludeTargets: [] })).toBe('in_scope') + expect(classifyTarget('other.example.com', { targets: [decoded], excludeTargets: [] })).toBe('out_of_scope') + }) +}) + +// The maintenance contract, made executable. +// +// `docs/TESTING.md` ends with "adding a config option? add its default to the +// table in config-options". A sentence in a doc is not a gate: `proximityBits`, +// `authorityFloor`, `staleAfter`, `alertFloor` and `publicSuffixes` all landed +// during one week of work on this subsystem, and each was one forgotten line +// away from shipping a default that nothing asserts. This walks the real +// default config and fails on anything the table has not accounted for. +describe('the defaults table covers every shipped option', () => { + /** Every leaf path of the default config, in dot form. Arrays count as leaves + * — a list default is a single value from the table's point of view. */ + function leafPaths(value: unknown, prefix = ''): string[] { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return prefix ? [prefix] : [] + return Object.entries(value as Record) + .flatMap(([k, v]) => leafPaths(v, prefix ? `${prefix}.${k}` : k)) + } + + // Asserted, but not as a literal: the adapter list has its own structural + // tests, and the registry URL is matched by shape so a repo move is not a + // failing test. + const ASSERTED_ELSEWHERE = new Set(['network.vpnAdapters', 'marketplace.defaultRegistryUrl']) + + it('no option ships a default that the table does not name', () => { + const covered = new Set(DEFAULTS.map(([path]) => path)) + const missing = leafPaths(loadConfig(tmpDir)) + .filter((p) => !covered.has(p) && !ASSERTED_ELSEWHERE.has(p)) + expect(missing, `add these to DEFAULTS (and to docs/TESTING.md Part 2): ${missing.join(', ')}`).toEqual([]) + }) + + it('the table names no option that has been removed', () => { + const live = new Set(leafPaths(loadConfig(tmpDir))) + const stale = DEFAULTS.map(([path]) => path).filter((p) => !live.has(p)) + expect(stale, `these are in DEFAULTS but no longer in the config: ${stale.join(', ')}`).toEqual([]) + }) + + it('every exemption still corresponds to a real option', () => { + const live = new Set(leafPaths(loadConfig(tmpDir))) + for (const path of ASSERTED_ELSEWHERE) expect(live.has(path)).toBe(true) + }) +}) + diff --git a/test/config.test.ts b/test/config.test.ts index baf79d4f..3a22de86 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -21,7 +21,7 @@ describe('loadConfig', () => { expect(config.network.whitelist).toEqual([]) expect(config.network.blacklist).toEqual([]) expect(config.network.checkInterval).toBe(60) - expect(config.scope.warnOnViolation).toBe(true) + expect(config.scope.alertFloor).toBe('adjacent') }) it('merges partial config with defaults', () => { @@ -51,18 +51,18 @@ describe('loadConfig', () => { expect(config.network.whitelist).toEqual(['10.0.0.0/8']) }) - it('migrates scope.enforcement: warn → warnOnViolation: true', () => { + it('migrates scope.enforcement: warn → alertFloor: adjacent', () => { fs.writeFileSync(path.join(tmpDir, 'config.yaml'), 'scope:\n enforcement: warn\n') const config = loadConfig(tmpDir) - expect(config.scope.warnOnViolation).toBe(true) + expect(config.scope.alertFloor).toBe('adjacent') expect((config.scope as unknown as { enforcement?: string }).enforcement).toBeUndefined() }) - it('migrates scope.enforcement: log → warnOnViolation: false', () => { + it('migrates scope.enforcement: log → alertFloor: excluded_only', () => { // Old 'log' mode did nothing; the direct semantic equivalent is warnings off. fs.writeFileSync(path.join(tmpDir, 'config.yaml'), 'scope:\n enforcement: log\n') const config = loadConfig(tmpDir) - expect(config.scope.warnOnViolation).toBe(false) + expect(config.scope.alertFloor).toBe('excluded_only') }) }) diff --git a/test/deconfliction.test.ts b/test/deconfliction.test.ts index ca73f192..2a2c6ef2 100644 --- a/test/deconfliction.test.ts +++ b/test/deconfliction.test.ts @@ -41,7 +41,7 @@ describe('deconfliction webhook', () => { url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/soc` }) afterEach(async () => { - configureDeconfliction({ enabled: false, url: '', secret: '', events: [], subtypes: [], includeData: false }) + configureDeconfliction({ enabled: false, url: '', secret: '', events: [], subtypes: [], includeData: false, authorityFloor: 'inferred' }) await new Promise((r) => server.close(() => r())) }) @@ -136,6 +136,76 @@ describe('deconfliction webhook', () => { expect(r.ok).toBe(false) }) + // G-C2. A `scope_violation` for a neighbouring host is a proximity INFERENCE; + // one for an explicitly excluded target is an observed rule match. Before + // this the blue team received both in the same shape, with nothing on the + // wire to tell them apart — an inference delivered as a fact. + describe('authority tiering', () => { + const violation = (authority: 'fact' | 'inferred', reason: string): RedLogEvent => + ev({ agentType: 'system', data: { subtype: 'scope_violation', reason, authority } }) + + const bodyOf = (): Record => JSON.parse(received[0].body)[0] + + it('labels every forwarded event with its authority and reason', async () => { + on({ events: ['system'] }) + notifyDeconfliction(violation('inferred', 'adjacent_subnet')) + _flushDeconflictionForTest() + await settle() + expect(bodyOf().authority).toBe('inferred') + expect(bodyOf().reason).toBe('adjacent_subnet') + }) + + // Both are bounded enums, so they ride outside the PII gate — the receiver + // must be able to triage without being handed the command text. + it('carries the labels even with includeData off', async () => { + on({ events: ['system'], includeData: false }) + notifyDeconfliction(violation('fact', 'excluded_target')) + _flushDeconflictionForTest() + await settle() + expect(bodyOf().authority).toBe('fact') + expect(bodyOf().data).toBeUndefined() + }) + + it('forwards both tiers by default — narrowing an outward feed is deliberate', async () => { + on({ events: ['system'] }) + notifyDeconfliction(violation('inferred', 'adjacent_domain')) + notifyDeconfliction(violation('fact', 'excluded_target')) + _flushDeconflictionForTest() + await settle() + expect(JSON.parse(received[0].body)).toHaveLength(2) + }) + + it('a fact floor holds the inference back and lets the rule match through', async () => { + on({ events: ['system'], authorityFloor: 'fact' }) + notifyDeconfliction(violation('inferred', 'adjacent_domain')) + notifyDeconfliction(violation('fact', 'excluded_target')) + _flushDeconflictionForTest() + await settle() + const batch = JSON.parse(received[0].body) + expect(batch).toHaveLength(1) + expect(batch[0].reason).toBe('excluded_target') + }) + + // Absence of `authority` is not an inferred claim. Silently cutting the + // blue team off from every other event type is the wrong way to fail. + it('an event with no authority field still forwards under a fact floor', async () => { + on({ events: ['marker'], authorityFloor: 'fact' }) + notifyDeconfliction(ev({ agentType: 'marker' })) + _flushDeconflictionForTest() + await settle() + expect(received).toHaveLength(1) + }) + + // The gate runs after the match, so it holds whichever branch matched. + it('the floor applies to subtype-matched events too, not just agent-type ones', async () => { + on({ events: [], subtypes: ['scope_violation'], authorityFloor: 'fact' }) + notifyDeconfliction(violation('inferred', 'adjacent_subnet')) + _flushDeconflictionForTest() + await settle() + expect(received).toHaveLength(0) + }) + }) + it('round-trips its config', () => { on({ events: ['marker', 'loot'], subtypes: ['scope_violation'] }) expect(getDeconflictionConfig()).toMatchObject({ enabled: true, events: ['marker', 'loot'], subtypes: ['scope_violation'] }) diff --git a/test/dot-shape.test.ts b/test/dot-shape.test.ts new file mode 100644 index 00000000..4c284953 --- /dev/null +++ b/test/dot-shape.test.ts @@ -0,0 +1,58 @@ +// The §3 rendering rule (K1). Before this, "solid = fact, dashed = inferred" +// existed only on the phase ribbon and was decided by which array a band came +// from — nothing could assert that an inferred EVENT renders differently from +// an observed one. RedLog never blocks, so a suggestion that presents as a +// recorded fact is the whole §3 guarantee failing silently. + +import { describe, it, expect } from 'vitest' +import { dotShape, isInferredEvent } from '../src/renderer/src/lib/dotShape' + +const ev = (agentType: string, data: Record = {}): { agentType: string; data: Record } => + ({ agentType, data }) + +describe('isInferredEvent', () => { + it('reads the stamp insertEvent wrote — it does not re-derive the classification', () => { + expect(isInferredEvent(ev('loot', { authority: 'inferred' }))).toBe(true) + // No stamp means fact, even for a type whose default IS inferred: the + // renderer must not keep a second copy of the table (that is the drift K1 + // exists to end). An unstamped loot row is a row core did not classify. + expect(isInferredEvent(ev('loot', {}))).toBe(false) + expect(isInferredEvent(ev('shell', { authority: 'fact' }))).toBe(false) + }) + + it('ignores junk rather than treating it as a claim', () => { + expect(isInferredEvent(ev('loot', { authority: 'maybe' }))).toBe(false) + expect(isInferredEvent({ agentType: 'loot' })).toBe(false) + }) +}) + +describe('dotShape — shape and authority are orthogonal', () => { + it('keeps the existing shape vocabulary unchanged', () => { + expect(dotShape(ev('system', { subtype: 'scope_violation' })).shape).toBe('diamond') + expect(dotShape(ev('marker', { severity: 'critical' })).shape).toBe('ring') + expect(dotShape(ev('marker', { severity: 'important' })).scale).toBe(1.25) + expect(dotShape(ev('shell', { subtype: 'command_end' })).shape).toBe('circle') + }) + + // The same glyph, two stroke styles: a scope violation is a diamond whether + // it is an excluded-target FACT or a proximity INFERENCE. + it('carries the tier alongside the shape, not instead of it', () => { + const fact = dotShape(ev('system', { subtype: 'scope_violation', reason: 'excluded_target', authority: 'fact' })) + const inferred = dotShape(ev('system', { subtype: 'scope_violation', reason: 'adjacent_subnet', authority: 'inferred' })) + expect(fact.shape).toBe('diamond') + expect(inferred.shape).toBe('diamond') + expect(fact.inferred).toBe(false) + expect(inferred.inferred).toBe(true) + }) + + it('marks a detector-derived event as inferred at every shape', () => { + expect(dotShape(ev('loot', { authority: 'inferred' })).inferred).toBe(true) + expect(dotShape(ev('pivot', { authority: 'inferred' })).inferred).toBe(true) + }) + + it('leaves observed capture solid', () => { + for (const t of ['shell', 'http', 'screenshot', 'agent', 'marker']) { + expect(dotShape(ev(t)).inferred).toBe(false) + } + }) +}) diff --git a/test/empty-state.test.ts b/test/empty-state.test.ts new file mode 100644 index 00000000..dcf3ea01 --- /dev/null +++ b/test/empty-state.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest' +import en from '../src/renderer/src/i18n/en.json' +import { emptyStateFor } from '../src/renderer/src/lib/emptyState' +import type { EmptyView } from '../src/renderer/src/lib/emptyState' + +// emptyStateFor is the pure decision seam behind UX-AUDIT F4: given a view and +// the capture context, it returns which existing i18n keys describe the empty +// state and what the single call-to-action should be. It reuses i18n keys that +// already ship (asserted below against en.json) and emits stable, non-i18n +// action identifiers that the wiring step resolves to labels later. + +// Every title/subtitle key this seam returns must already exist in en.json — the +// seam is forbidden from inventing new i18n keys (they would collide with the +// live i18n file). Action labelKeys are deliberately NOT in en.json yet. +const hasKey = (k: string): boolean => Object.prototype.hasOwnProperty.call(en, k) + +const ALL_VIEWS: EmptyView[] = ['timeline', 'screenshots', 'targets', 'loot', 'marks', 'transcript'] + +describe('emptyStateFor — reuses existing i18n keys', () => { + for (const captureDark of [false, true]) { + for (const view of ALL_VIEWS) { + it(`${view} (captureDark=${captureDark}) titleKey + subtitleKey exist in en.json`, () => { + const m = emptyStateFor(view, { captureDark }) + expect(hasKey(m.titleKey), `titleKey ${m.titleKey} missing from en.json`).toBe(true) + expect(hasKey(m.subtitleKey), `subtitleKey ${m.subtitleKey} missing from en.json`).toBe(true) + }) + } + } + + // Post-wiring (Wave 2-B): the action labelKeys now exist in en.json — the + // views render them via t(). This guards i18n completeness for every CTA the + // seam can emit, so a new action target can't ship without its copy. + it('every action labelKey resolves to an i18n key', () => { + for (const view of ALL_VIEWS) { + for (const captureDark of [false, true]) { + const a = emptyStateFor(view, { captureDark }).action + if (a) expect(hasKey(a.labelKey), `${a.labelKey} missing from en.json`).toBe(true) + } + } + }) +}) + +describe('emptyStateFor — per-view mapping (light capture)', () => { + const ctx = { captureDark: false } + + it('timeline: reuses noEvents/noEventsDesc and has NO action until events arrive', () => { + const m = emptyStateFor('timeline', ctx) + expect(m.titleKey).toBe('timeline.noEvents') + expect(m.subtitleKey).toBe('timeline.noEventsDesc') + expect(m.action).toBeUndefined() + }) + + it('screenshots: reuses empty/emptyDesc and CTAs to capture now', () => { + const m = emptyStateFor('screenshots', ctx) + expect(m.titleKey).toBe('screenshots.empty') + expect(m.subtitleKey).toBe('screenshots.emptyDesc') + expect(m.action).toEqual({ labelKey: 'empty.action.captureNow', target: 'screenshot' }) + }) + + it('targets: reuses empty/subtitle and CTAs to the doc', () => { + const m = emptyStateFor('targets', ctx) + expect(m.titleKey).toBe('targets.empty') + expect(m.subtitleKey).toBe('targets.subtitle') + expect(m.action).toEqual({ labelKey: 'empty.action.learnMore', target: 'doc' }) + }) + + it('loot: reuses empty/emptyDesc and CTAs to set up capture', () => { + const m = emptyStateFor('loot', ctx) + expect(m.titleKey).toBe('loot.empty') + expect(m.subtitleKey).toBe('loot.emptyDesc') + expect(m.action).toEqual({ labelKey: 'empty.action.setupCapture', target: 'dashboard' }) + }) + + it('marks: reuses empty/placeholderSub and CTAs to the mark shortcut', () => { + const m = emptyStateFor('marks', ctx) + expect(m.titleKey).toBe('marks.empty') + expect(m.subtitleKey).toBe('marks.placeholderSub') + expect(m.action).toEqual({ labelKey: 'empty.action.mark', target: 'marker' }) + }) + + it('transcript: reuses transcript.empty + a shared events subtitle and CTAs to the doc', () => { + const m = emptyStateFor('transcript', ctx) + expect(m.titleKey).toBe('transcript.empty') + expect(m.subtitleKey).toBe('timeline.noEventsDesc') + expect(m.action).toEqual({ labelKey: 'empty.action.learnMore', target: 'doc' }) + }) +}) + +describe('emptyStateFor — capture-dark variant redirects to set up capture', () => { + const ctx = { captureDark: true } + + it('timeline: dark timeline now has a set-up-capture CTA (nothing will arrive otherwise)', () => { + const m = emptyStateFor('timeline', ctx) + expect(m.titleKey).toBe('timeline.noEvents') + expect(m.action).toEqual({ labelKey: 'empty.action.setupCapture', target: 'dashboard' }) + }) + + it('loot/targets/transcript/marks all redirect to dashboard when capture is dark', () => { + for (const view of ['loot', 'targets', 'transcript', 'marks'] as EmptyView[]) { + const m = emptyStateFor(view, ctx) + expect(m.action, `${view} should route to setup capture when dark`).toEqual({ + labelKey: 'empty.action.setupCapture', + target: 'dashboard' + }) + } + }) + + it('screenshots keeps its capture-now CTA even when dark (it IS a capture source)', () => { + const m = emptyStateFor('screenshots', ctx) + expect(m.action).toEqual({ labelKey: 'empty.action.captureNow', target: 'screenshot' }) + }) + + it('titleKey/subtitleKey are unaffected by captureDark', () => { + for (const view of ALL_VIEWS) { + const light = emptyStateFor(view, { captureDark: false }) + const dark = emptyStateFor(view, { captureDark: true }) + expect(dark.titleKey).toBe(light.titleKey) + expect(dark.subtitleKey).toBe(light.subtitleKey) + } + }) +}) diff --git a/test/io-store.test.ts b/test/io-store.test.ts new file mode 100644 index 00000000..2ed18f47 --- /dev/null +++ b/test/io-store.test.ts @@ -0,0 +1,251 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import fs from 'fs' +import path from 'path' +import os from 'os' +import crypto from 'crypto' +import { putBody, readBody, resolveRef, resolveExisting, verifyBody, ioDir, stampIoRefs, compressBody, ioStoreSize, MAX_IO_READ_BYTES } from '../src/core/io-store' + +// io_ref sidecar store (SPEC-IO-SIDECAR.md step 1). Content-addressed, deduped, +// range-readable, path-guarded. The chain only ever sees the sha256 these +// functions return — so their correctness is what makes verify meaningful. + +let dir: string + +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'redlog-io-')) +}) +afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }) +}) + +const sha = (b: Buffer): string => crypto.createHash('sha256').update(b).digest('hex') + +describe('putBody', () => { + it('stores a body under its sha256 and returns the ref/len/sha256', () => { + const body = Buffer.from('{"hello":"world"}') + const r = putBody(dir, body) + expect(r.sha256).toBe(sha(body)) + expect(r.ref).toBe(r.sha256) + expect(r.len).toBe(body.length) + const onDisk = fs.readFileSync(path.join(ioDir(dir), `${r.ref}.bin`)) + expect(onDisk.equals(body)).toBe(true) + }) + + it('dedups identical bodies to a single file (A3)', () => { + const body = Buffer.from('same bytes') + const a = putBody(dir, body) + const b = putBody(dir, body) + expect(a.ref).toBe(b.ref) + const files = fs.readdirSync(ioDir(dir)).filter((f) => f.endsWith('.bin')) + expect(files).toHaveLength(1) + }) + + it('stores distinct bodies separately', () => { + putBody(dir, Buffer.from('one')) + putBody(dir, Buffer.from('two')) + const files = fs.readdirSync(ioDir(dir)).filter((f) => f.endsWith('.bin')) + expect(files).toHaveLength(2) + }) + + it('leaves no .tmp files behind', () => { + putBody(dir, Buffer.from('x')) + const tmp = fs.readdirSync(ioDir(dir)).filter((f) => f.includes('.tmp')) + expect(tmp).toHaveLength(0) + }) +}) + +describe('readBody', () => { + it('round-trips a full body (A1)', () => { + const body = Buffer.from('x'.repeat(40 * 1024)) // > REDLOG_MAX_BODY inline cap + const { ref } = putBody(dir, body) + const got = readBody(dir, ref) + expect(got).not.toBeNull() + expect(got!.equals(body)).toBe(true) + }) + + it('reads a byte range [off, off+len)', () => { + const body = Buffer.from('0123456789') + const { ref } = putBody(dir, body) + expect(readBody(dir, ref, 2, 3)!.toString()).toBe('234') + expect(readBody(dir, ref, 8, 5)!.toString()).toBe('89') // clamped to EOF + }) + + it('returns null for an out-of-range or empty request', () => { + const { ref } = putBody(dir, Buffer.from('abc')) + expect(readBody(dir, ref, 10, 1)).toBeNull() // off past EOF + expect(readBody(dir, ref, 0, 0)).toBeNull() // len <= 0 + expect(readBody(dir, ref, -1, 2)).toBeNull() // negative off + }) + + it('returns null when the body was pruned (A4 pruned, not tampered)', () => { + const { ref } = putBody(dir, Buffer.from('gone')) + fs.unlinkSync(path.join(ioDir(dir), `${ref}.bin`)) + expect(readBody(dir, ref)).toBeNull() + // ref is still well-formed → caller reads this as pruned, not traversal + expect(resolveRef(dir, ref)).not.toBeNull() + }) + + it('refuses a whole-body read above the cap (must page instead)', () => { + const { ref } = putBody(dir, Buffer.from('small')) + // A range read up to the cap is fine; asking for more than the cap is refused. + expect(readBody(dir, ref, 0, MAX_IO_READ_BYTES + 1)).toBeNull() + }) +}) + +describe('path traversal (A7)', () => { + it('refuses a non-sha256 ref', () => { + expect(resolveRef(dir, '../../etc/passwd')).toBeNull() + expect(resolveRef(dir, 'not-a-hash')).toBeNull() + expect(resolveRef(dir, '')).toBeNull() + expect(readBody(dir, '../../etc/passwd')).toBeNull() + }) + + it('refuses an uppercase or wrong-length hex ref', () => { + expect(resolveRef(dir, 'A'.repeat(64))).toBeNull() // uppercase + expect(resolveRef(dir, 'a'.repeat(63))).toBeNull() // too short + expect(resolveRef(dir, 'a'.repeat(65))).toBeNull() // too long + }) +}) + +describe('stampIoRefs (option B, A2)', () => { + it('sidecars a posted full body and replaces it with an io ref', () => { + const body = 'y'.repeat(40 * 1024) + const data: Record = { + subtype: 'http_response', + response_preview: body.slice(0, 16384), + response_body_full: body, + response_body_ct: 'application/json', + } + stampIoRefs(data, dir) + // full bytes are gone from the event; only the ref + preview remain (A2) + expect(data.response_body_full).toBeUndefined() + expect(data.response_body_ct).toBeUndefined() + expect(data.response_preview).toBe(body.slice(0, 16384)) + const io = data.io as { response: { ref: string; len: number; sha256: string; ct: string; truncated: boolean } } + expect(io.response.sha256).toBe(sha(Buffer.from(body, 'utf8'))) + expect(io.response.ref).toBe(io.response.sha256) + expect(io.response.len).toBe(Buffer.byteLength(body, 'utf8')) + expect(io.response.ct).toBe('application/json') + expect(io.response.truncated).toBe(false) + // and the bytes really landed in the sidecar + expect(readBody(dir, io.response.ref)!.toString()).toBe(body) + }) + + it('stamps both request and response slots on the same event', () => { + const data: Record = { + request_body_full: 'req'.repeat(9000), + response_body_full: 'resp'.repeat(9000), + } + stampIoRefs(data, dir) + const io = data.io as { request: unknown; response: unknown } + expect(io.request).toBeDefined() + expect(io.response).toBeDefined() + }) + + it('carries truncated:true through from the addon ceiling flag', () => { + const data: Record = { + response_body_full: 'z'.repeat(1000), + response_body_full_truncated: true, + } + stampIoRefs(data, dir) + expect((data.io as { response: { truncated: boolean } }).response.truncated).toBe(true) + }) + + it('is a no-op for events with no full-body field (historical/small bodies)', () => { + const data: Record = { subtype: 'http_response', response_preview: 'small complete body' } + stampIoRefs(data, dir) + expect(data.io).toBeUndefined() + expect(fs.existsSync(ioDir(dir))).toBe(false) // nothing written + }) + + it('strips a stray full-body field even when empty, never chaining raw bytes', () => { + const data: Record = { request_body_full: '', request_body_ct: 'text/plain' } + stampIoRefs(data, dir) + expect(data.request_body_full).toBeUndefined() + expect(data.request_body_ct).toBeUndefined() + expect(data.io).toBeUndefined() + }) +}) + +describe('warm compression (SPEC-SCOPE-AWARE-LIFECYCLE Part C, A4)', () => { + it('compresses a body in place, reclaiming the raw file but keeping the digest', () => { + const body = Buffer.from(JSON.stringify({ blob: 'A'.repeat(50 * 1024) })) // compressible + const { ref } = putBody(dir, body) + const rawSize = fs.statSync(path.join(ioDir(dir), `${ref}.bin`)).size + expect(compressBody(dir, ref)).toBe(true) + // raw gone, gz present and smaller + expect(fs.existsSync(path.join(ioDir(dir), `${ref}.bin`))).toBe(false) + const gzPath = path.join(ioDir(dir), `${ref}.bin.gz`) + expect(fs.existsSync(gzPath)).toBe(true) + expect(fs.statSync(gzPath).size).toBeLessThan(rawSize) + // resolveExisting reports it as compressed + expect(resolveExisting(dir, ref)).toEqual({ file: gzPath, compressed: true }) + }) + + it('reads a warm body back transparently — full and ranged', () => { + const body = Buffer.from('0123456789'.repeat(2000)) + const { ref } = putBody(dir, body) + compressBody(dir, ref) + expect(readBody(dir, ref)!.equals(body)).toBe(true) + expect(readBody(dir, ref, 5, 5)!.toString()).toBe('56789') + }) + + it('verifies a warm body against its ORIGINAL sha256 (A4)', () => { + const body = Buffer.from('attested then compressed') + const { ref } = putBody(dir, body) + compressBody(dir, ref) + expect(verifyBody(dir, ref)).toBe(true) + }) + + it('detects tampering of a warm body', () => { + const { ref } = putBody(dir, Buffer.from('original body')) + compressBody(dir, ref) + // overwrite the gz with a valid gzip of different bytes + const zlib = require('zlib') as typeof import('zlib') + fs.writeFileSync(path.join(ioDir(dir), `${ref}.bin.gz`), zlib.gzipSync(Buffer.from('tampered'))) + expect(verifyBody(dir, ref)).toBe(false) + }) + + it('putBody dedups against an already-warm body (no raw rewrite)', () => { + const body = Buffer.from('dedup me') + const { ref } = putBody(dir, body) + compressBody(dir, ref) + putBody(dir, body) // same bytes, already compressed + expect(fs.existsSync(path.join(ioDir(dir), `${ref}.bin`))).toBe(false) // not re-materialized raw + const files = fs.readdirSync(ioDir(dir)).filter((f) => f.startsWith(ref)) + expect(files).toEqual([`${ref}.bin.gz`]) + }) + + it('compressBody is a no-op-true when already warm, false when pruned', () => { + const { ref } = putBody(dir, Buffer.from('x')) + compressBody(dir, ref) + expect(compressBody(dir, ref)).toBe(true) // idempotent + fs.unlinkSync(path.join(ioDir(dir), `${ref}.bin.gz`)) + expect(compressBody(dir, ref)).toBe(false) // gone + }) + + it('ioStoreSize sums raw and warm bodies', () => { + putBody(dir, Buffer.from('a'.repeat(1000))) + const { ref } = putBody(dir, Buffer.from('b'.repeat(1000))) + compressBody(dir, ref) + expect(ioStoreSize(dir)).toBeGreaterThan(0) + }) +}) + +describe('verifyBody (A4)', () => { + it('confirms on-disk bytes match the chained digest', () => { + const { ref } = putBody(dir, Buffer.from('attested bytes')) + expect(verifyBody(dir, ref)).toBe(true) + }) + + it('fails when the stored bytes are tampered', () => { + const { ref } = putBody(dir, Buffer.from('original')) + fs.writeFileSync(path.join(ioDir(dir), `${ref}.bin`), Buffer.from('tampered')) + expect(verifyBody(dir, ref)).toBe(false) + }) + + it('fails (not throws) on a pruned or malformed ref', () => { + expect(verifyBody(dir, 'a'.repeat(64))).toBe(false) // never written + expect(verifyBody(dir, 'bad')).toBe(false) + }) +}) diff --git a/test/ip-badge.test.ts b/test/ip-badge.test.ts new file mode 100644 index 00000000..76157178 --- /dev/null +++ b/test/ip-badge.test.ts @@ -0,0 +1,75 @@ +// The shared display decision (G-A3). StatusBar, IPStatusCard and the overlay +// HUD style themselves differently but must never disagree about the verdict, +// so the decision lives in one pure function and is pinned here. + +import { describe, it, expect } from 'vitest' +import { ipBadge } from '../src/renderer/src/lib/ip-badge' + +describe('ipBadge', () => { + it('a live verdict renders at full confidence', () => { + expect(ipBadge({ ipSafety: 'safe' })).toEqual({ severity: 'ok', qualified: false, reason: null }) + expect(ipBadge({ ipSafety: 'exposed' })).toEqual({ severity: 'critical', qualified: false, reason: null }) + }) + + it('settling keeps the severity but qualifies it — the address is unconfirmed, the verdict is not dead', () => { + expect(ipBadge({ ipSafety: 'safe', settling: true })) + .toEqual({ severity: 'ok', qualified: true, reason: 'settling' }) + }) + + it('stale forces unknown severity — a green badge must never outlive its reading', () => { + expect(ipBadge({ ipSafety: 'safe', stale: true })) + .toEqual({ severity: 'unknown', qualified: true, reason: 'stale' }) + }) + + it('stale wins over settling: no reading is a stronger statement than an unconfirmed one', () => { + expect(ipBadge({ ipSafety: 'exposed', settling: true, stale: true })) + .toEqual({ severity: 'unknown', qualified: true, reason: 'stale' }) + }) + + // Defensive: the main process already decays the verdict, but a surface must + // not present green if the two ever drift apart. + it('forces the severity rather than trusting a stale-but-undecayed status', () => { + expect(ipBadge({ ipSafety: 'safe', stale: true }).severity).toBe('unknown') + }) + + it('no status at all is unknown, not qualified — nothing has expired yet', () => { + expect(ipBadge(null)).toEqual({ severity: 'unknown', qualified: false, reason: null }) + expect(ipBadge(undefined)).toEqual({ severity: 'unknown', qualified: false, reason: null }) + }) +}) + +// G-A2 meets G-A3 meets G-C1: five verdicts, four SHARED severity steps (the +// scope alarm uses the same four). `presumed_safe` sits on `ok` like `safe` and +// is separated by `qualified`, so a surface paints four colours rather than five +// — and an inference can never render as a solid green fill. +describe('ipBadge — the five verdicts', () => { + it('gives off_profile its own severity step, unqualified — it is an observation', () => { + expect(ipBadge({ ipSafety: 'off_profile' })) + .toEqual({ severity: 'warn', qualified: false, reason: null }) + }) + + it('folds presumed_safe onto the ok step but never at full confidence', () => { + expect(ipBadge({ ipSafety: 'presumed_safe' })) + .toEqual({ severity: 'ok', qualified: true, reason: 'presumed' }) + }) + + it('a verified safe stays unqualified — the two must not look alike', () => { + const verified = ipBadge({ ipSafety: 'safe' }) + const presumed = ipBadge({ ipSafety: 'presumed_safe' }) + expect(verified.severity).toBe(presumed.severity) + expect(verified.qualified).toBe(false) + expect(presumed.qualified).toBe(true) + }) + + // Precedence runs from "how much do we know" outward: no reading beats an + // unconfirmed reading beats a confirmed reading we can only infer from. + it('a dead reading outranks an inference', () => { + expect(ipBadge({ ipSafety: 'presumed_safe', stale: true }).reason).toBe('stale') + expect(ipBadge({ ipSafety: 'presumed_safe', settling: true }).reason).toBe('settling') + }) + + it('off_profile survives settling with its own severity', () => { + expect(ipBadge({ ipSafety: 'off_profile', settling: true })) + .toEqual({ severity: 'warn', qualified: true, reason: 'settling' }) + }) +}) diff --git a/test/ip-match.test.ts b/test/ip-match.test.ts new file mode 100644 index 00000000..65a96c7a --- /dev/null +++ b/test/ip-match.test.ts @@ -0,0 +1,119 @@ +// The shared address matcher (G-A5). Both monitors route every IP decision +// through this, so a gap here is a gap in the whole alert subsystem. + +import { describe, it, expect } from 'vitest' +import { ipInCIDR, parseIPv4, parseIPv6, ipFamily, isIPLiteral } from '../src/core/ip-match' + +const hex = (b: Uint8Array | null): string => + b ? Array.from(b).map((n) => n.toString(16).padStart(2, '0')).join('') : 'null' + +describe('parseIPv6', () => { + const cases: Array<[string, string]> = [ + ['::', '00000000000000000000000000000000'], + ['::1', '00000000000000000000000000000001'], + ['2001:db8::1', '20010db8000000000000000000000001'], + ['2001:0db8:0000:0000:0000:0000:0000:0001', '20010db8000000000000000000000001'], + ['fe80::1%en0', 'fe800000000000000000000000000001'], // zone id stripped + ['[2001:db8::1]', '20010db8000000000000000000000001'], // URL brackets stripped + ['::ffff:192.0.2.1', '00000000000000000000ffffc0000201'], // embedded v4 + ['1:2:3:4:5:6:7:8', '00010002000300040005000600070008'] + ] + for (const [input, expected] of cases) { + it(`${input}`, () => expect(hex(parseIPv6(input))).toBe(expected)) + } + + const bad = ['2001:db8::1::2', '2001:db8:::1', 'gggg::1', '1:2:3:4:5:6:7', '1:2:3:4:5:6:7:8:9', '1.2.3.4', ''] + for (const input of bad) { + it(`rejects ${input || '(empty)'}`, () => expect(parseIPv6(input)).toBeNull()) + } + + it('rejects an embedded v4 that is not last', () => { + expect(parseIPv6('::192.0.2.1:1')).toBeNull() + }) +}) + +describe('parseIPv4', () => { + it('parses a dotted quad', () => expect(parseIPv4('10.8.0.5')).toBe(0x0a080005)) + it('parses the broadcast address without sign trouble', () => expect(parseIPv4('255.255.255.255')).toBe(4294967295)) + it('rejects an out-of-range octet', () => expect(parseIPv4('999.1.1.1')).toBeNull()) + it('rejects a short quad', () => expect(parseIPv4('10.8.0')).toBeNull()) +}) + +describe('ipFamily / isIPLiteral', () => { + it('classifies both families', () => { + expect(ipFamily('10.8.0.5')).toBe(4) + expect(ipFamily('2001:db8::1')).toBe(6) + }) + + // The form providers actually return; an operator whose whitelist says + // 10.8.0.0/24 means it to match. + it('reports an IPv4-mapped IPv6 address as IPv4', () => { + expect(ipFamily('::ffff:10.8.0.5')).toBe(4) + expect(ipInCIDR('::ffff:10.8.0.5', '10.8.0.0/24')).toBe(true) + }) + + it('a hostname is not an IP literal — this is what routed v6 to the domain matcher', () => { + expect(isIPLiteral('example.com')).toBe(false) + expect(isIPLiteral('2001:db8::1')).toBe(true) + expect(isIPLiteral('10.8.0.5')).toBe(true) + }) +}) + +describe('ipInCIDR — IPv4', () => { + it('matches inside the prefix', () => expect(ipInCIDR('192.168.1.55', '192.168.1.0/24')).toBe(true)) + it('rejects outside the prefix', () => expect(ipInCIDR('192.168.2.55', '192.168.1.0/24')).toBe(false)) + it('/32 is a single host', () => { + expect(ipInCIDR('10.0.0.1', '10.0.0.1/32')).toBe(true) + expect(ipInCIDR('10.0.0.2', '10.0.0.1/32')).toBe(false) + }) + it('/0 matches everything', () => expect(ipInCIDR('8.8.8.8', '0.0.0.0/0')).toBe(true)) + it('a bare address is exact equality', () => { + expect(ipInCIDR('10.0.0.1', '10.0.0.1')).toBe(true) + expect(ipInCIDR('10.0.0.2', '10.0.0.1')).toBe(false) + }) + it('matches high addresses that overflow a signed shift', () => { + expect(ipInCIDR('255.255.255.254', '255.255.255.0/24')).toBe(true) + }) +}) + +describe('ipInCIDR — IPv6 (the G-A5 defect)', () => { + // Every one of these answered false before: v6 was compared by string + // equality against the network part of the CIDR. + it('matches inside a /32', () => expect(ipInCIDR('2001:db8::1', '2001:db8::/32')).toBe(true)) + it('matches inside a /64', () => expect(ipInCIDR('2001:db8:0:1::dead', '2001:db8:0:1::/64')).toBe(true)) + it('rejects a neighbouring /64', () => expect(ipInCIDR('2001:db8:0:2::dead', '2001:db8:0:1::/64')).toBe(false)) + it('rejects outside the /32', () => expect(ipInCIDR('2001:db9::1', '2001:db8::/32')).toBe(false)) + it('/128 is a single host', () => { + expect(ipInCIDR('2001:db8::1', '2001:db8::1/128')).toBe(true) + expect(ipInCIDR('2001:db8::2', '2001:db8::1/128')).toBe(false) + }) + it('/0 matches everything', () => expect(ipInCIDR('2001:db8::1', '::/0')).toBe(true)) + + it('handles a prefix that is not a whole number of bytes', () => { + expect(ipInCIDR('2001:db8:8000::1', '2001:db8:8000::/33')).toBe(true) + expect(ipInCIDR('2001:db8:0::1', '2001:db8:8000::/33')).toBe(false) + }) + + // The second half of the defect: equivalent notations did not compare equal. + it('compares parsed values, not strings', () => { + expect(ipInCIDR('2001:0db8:0000:0000:0000:0000:0000:0001', '2001:db8::1')).toBe(true) + expect(ipInCIDR('2001:DB8::1', '2001:db8::1')).toBe(true) + }) +}) + +describe('ipInCIDR — refuses to guess', () => { + it('a family mismatch never matches', () => { + expect(ipInCIDR('10.8.0.5', '2001:db8::/32')).toBe(false) + expect(ipInCIDR('2001:db8::1', '10.8.0.0/24')).toBe(false) + }) + + // This decides whether to raise an alert, so junk must not accidentally match. + it('malformed input is false, not a wildcard', () => { + expect(ipInCIDR('not-an-ip', '10.0.0.0/8')).toBe(false) + expect(ipInCIDR('10.0.0.1', 'garbage/8')).toBe(false) + expect(ipInCIDR('10.0.0.1', '10.0.0.0/abc')).toBe(false) + expect(ipInCIDR('10.0.0.1', '10.0.0.0/33')).toBe(false) + expect(ipInCIDR('2001:db8::1', '2001:db8::/129')).toBe(false) + expect(ipInCIDR('999.1.1.1', '999.1.1.0/24')).toBe(false) + }) +}) diff --git a/test/ip-monitor-options.test.ts b/test/ip-monitor-options.test.ts new file mode 100644 index 00000000..385e1934 --- /dev/null +++ b/test/ip-monitor-options.test.ts @@ -0,0 +1,289 @@ +// Per-option coverage for the network block: every knob in `config.network` +// (checkInterval, confirmations, providers, ipMode, whitelist/blacklist ranges) +// against its default, its boundary values, and its junk values. +// +// `ip-monitor.test.ts` covers the settling narrative and the A-1..A-9 verdict +// matrix; `ip-monitor-dns.test.ts` covers dns/auto mode selection. This file is +// the "does each configured NUMBER actually take effect" half. + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { IPMonitor, classifyIP } from '../src/core/ip-monitor' + +function tick(m: IPMonitor): Promise { + return (m as unknown as { check: () => Promise }).check() +} + +/** Answer every provider with the same address. */ +function mockIP(ip: string): ReturnType { + const f = vi.fn(async () => ({ ok: true, json: async () => ({ ip }) } as unknown as Response)) + vi.stubGlobal('fetch', f) + return f +} + +/** Answer per-URL, so provider fallback order is observable. */ +function mockByUrl(map: Record): ReturnType { + const f = vi.fn(async (url: string) => { + const hit = map[url] + if (!hit || hit.throws) throw new Error(`unreachable: ${url}`) + return { ok: hit.ok !== false, json: async () => hit.body } as unknown as Response + }) + vi.stubGlobal('fetch', f) + return f +} + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('network.checkInterval — seconds in, milliseconds on the timer', () => { + it('defaults to a 10s poll when nothing is configured', () => { + mockIP('10.8.0.5') + const spy = vi.spyOn(globalThis, 'setInterval') + const m = new IPMonitor() + m.configure({ ipMode: 'http' }) + m.start() + expect(spy.mock.calls[0][1]).toBe(10_000) + m.stop() + }) + + it('converts the configured seconds to milliseconds', () => { + mockIP('10.8.0.5') + const spy = vi.spyOn(globalThis, 'setInterval') + const m = new IPMonitor() + m.configure({ checkInterval: 60, ipMode: 'http' }) // the shipped default + m.start() + expect(spy.mock.calls[0][1]).toBe(60_000) + m.stop() + }) + + it('ignores 0 — a zero-second poll would be a busy loop', () => { + mockIP('10.8.0.5') + const spy = vi.spyOn(globalThis, 'setInterval') + const m = new IPMonitor() + m.configure({ checkInterval: 0, ipMode: 'http' }) + m.start() + expect(spy.mock.calls[0][1]).toBe(10_000) + m.stop() + }) + + it('stop() clears the timer so a closed project stops polling', () => { + mockIP('10.8.0.5') + const clear = vi.spyOn(globalThis, 'clearInterval') + const m = new IPMonitor() + m.configure({ ipMode: 'http' }) + m.start() + m.stop() + expect(clear).toHaveBeenCalled() + m.stop() // idempotent + }) +}) + +describe('network.confirmations — how many identical reads promote a new address', () => { + async function promoteAfter(confirmations: number | undefined, reads: number): Promise { + const m = new IPMonitor() + m.configure({ whitelist: ['10.8.0.0/24'], confirmations, ipMode: 'http' }) + mockIP('10.8.0.5') + await tick(m) // first reading is taken as-is + mockIP('5.6.7.8') + for (let i = 0; i < reads; i++) await tick(m) + return m.status.externalIP + } + + it('1 = promote on sight (no flap protection)', async () => { + expect(await promoteAfter(1, 1)).toBe('5.6.7.8') + }) + + it('3 (the default) holds the old address for two reads, promotes on the third', async () => { + expect(await promoteAfter(undefined, 2)).toBe('10.8.0.5') + expect(await promoteAfter(undefined, 3)).toBe('5.6.7.8') + }) + + it('5 holds for four reads', async () => { + expect(await promoteAfter(5, 4)).toBe('10.8.0.5') + expect(await promoteAfter(5, 5)).toBe('5.6.7.8') + }) + + it('0 is rejected — falls back to the default of 3, not "promote instantly"', async () => { + expect(await promoteAfter(0, 2)).toBe('10.8.0.5') + expect(await promoteAfter(0, 3)).toBe('5.6.7.8') + }) + + it('a negative value is rejected the same way', async () => { + expect(await promoteAfter(-1, 2)).toBe('10.8.0.5') + }) + + it('settling stays true for the whole hold, then clears on promotion', async () => { + const m = new IPMonitor() + m.configure({ whitelist: ['10.8.0.0/24'], confirmations: 3, ipMode: 'http' }) + mockIP('10.8.0.5') + await tick(m) + expect(m.status.settling).toBe(false) + mockIP('5.6.7.8') + await tick(m); expect(m.status.settling).toBe(true) + await tick(m); expect(m.status.settling).toBe(true) + await tick(m); expect(m.status.settling).toBe(false) + }) + + it('the displayed verdict does not change while settling — no badge flicker', async () => { + const m = new IPMonitor() + m.configure({ whitelist: ['10.8.0.0/24'], blacklist: ['5.6.7.8'], confirmations: 3, ipMode: 'http' }) + mockIP('10.8.0.5') + await tick(m) + mockIP('5.6.7.8') + await tick(m) + expect(m.status.ipSafety).toBe('safe') // still the last stable read + await tick(m) + await tick(m) + expect(m.status.ipSafety).toBe('exposed') // and only now the alarm + }) +}) + +describe('network.providers — HTTP echo list', () => { + it('falls through to the next provider when the first one throws', async () => { + const f = mockByUrl({ + 'https://a.test/ip': { throws: true }, + 'https://b.test/ip': { body: { ip: '5.6.7.8' } } + }) + const m = new IPMonitor() + m.configure({ providers: ['https://a.test/ip', 'https://b.test/ip'], ipMode: 'http' }) + await tick(m) + expect(m.status.externalIP).toBe('5.6.7.8') + expect(f).toHaveBeenCalledTimes(2) + }) + + it('treats a non-200 as a failure and moves on', async () => { + mockByUrl({ + 'https://a.test/ip': { ok: false, body: { ip: '1.1.1.1' } }, + 'https://b.test/ip': { body: { ip: '5.6.7.8' } } + }) + const m = new IPMonitor() + m.configure({ providers: ['https://a.test/ip', 'https://b.test/ip'], ipMode: 'http' }) + await tick(m) + expect(m.status.externalIP).toBe('5.6.7.8') + }) + + it('accepts the {origin} shape as well as {ip}', async () => { + mockByUrl({ 'https://a.test/ip': { body: { origin: '5.6.7.8' } } }) + const m = new IPMonitor() + m.configure({ providers: ['https://a.test/ip'], ipMode: 'http' }) + await tick(m) + expect(m.status.externalIP).toBe('5.6.7.8') + }) + + it('an empty provider list is ignored — the built-in list is used', async () => { + const f = mockIP('5.6.7.8') + const m = new IPMonitor() + m.configure({ providers: [], ipMode: 'http' }) + await tick(m) + expect(String(f.mock.calls[0][0])).toContain('ipify.org') + }) + + it('every provider failing sets an error and keeps the last known address', async () => { + const m = new IPMonitor() + m.configure({ providers: ['https://a.test/ip', 'https://b.test/ip'], ipMode: 'http' }) + mockByUrl({ 'https://a.test/ip': { body: { ip: '5.6.7.8' } } }) + await tick(m) + mockByUrl({}) // both unreachable + await tick(m) + expect(m.status.externalIP).toBe('5.6.7.8') + expect(m.status.error).toBe('All IP providers failed') + }) + + it('a later success clears the error', async () => { + const m = new IPMonitor() + m.configure({ providers: ['https://a.test/ip'], confirmations: 1, ipMode: 'http' }) + mockByUrl({}) + await tick(m) + expect(m.status.error).toBeTruthy() + mockByUrl({ 'https://a.test/ip': { body: { ip: '5.6.7.8' } } }) + await tick(m) + expect(m.status.error).toBeNull() + }) +}) + +describe('network.ipMode = http', () => { + it('never touches DNS — every read is an HTTP GET', async () => { + const f = mockIP('5.6.7.8') + const m = new IPMonitor() + m.configure({ ipMode: 'http' }) + await tick(m) + expect(f).toHaveBeenCalled() + expect(m.status.externalIP).toBe('5.6.7.8') + }) +}) + +describe('overlapping checks', () => { + it('a slow poll is not re-entered by the next tick', async () => { + let resolve!: (v: unknown) => void + const gate = new Promise((r) => { resolve = r }) + const f = vi.fn(async () => { + await gate + return { ok: true, json: async () => ({ ip: '5.6.7.8' }) } as unknown as Response + }) + vi.stubGlobal('fetch', f) + const m = new IPMonitor() + m.configure({ ipMode: 'http' }) + const first = tick(m) + await tick(m) // returns immediately, guarded + expect(f).toHaveBeenCalledTimes(1) + resolve(null) + await first + }) +}) + +// The range arithmetic behind "did my egress land inside the list I declared". +// classifyIP is pure, so every boundary is a one-liner. +describe('whitelist / blacklist range matching', () => { + const wl = (cidr: string, ip: string): string => classifyIP(ip, { whitelist: [cidr], blacklist: [] }) + + const inRange: Array<[string, string]> = [ + ['10.8.0.0/24', '10.8.0.0'], // network address + ['10.8.0.0/24', '10.8.0.255'], // broadcast address + ['10.8.0.0/24', '10.8.0.1'], + ['172.16.0.0/12', '172.31.255.255'], + ['203.0.113.42/32', '203.0.113.42'], + ['0.0.0.0/0', '198.51.100.7'], // match-everything + ['203.0.113.42', '203.0.113.42'] // bare IP, no mask + ] + for (const [cidr, ip] of inRange) { + it(`${ip} is inside ${cidr}`, () => expect(wl(cidr, ip)).toBe('safe')) + } + + const outOfRange: Array<[string, string]> = [ + ['10.8.0.0/24', '10.8.1.0'], // one past the top + ['10.8.0.0/24', '10.7.255.255'], // one below the bottom + ['172.16.0.0/12', '172.32.0.1'], + ['203.0.113.42/32', '203.0.113.43'], + ['203.0.113.42', '203.0.113.4'], // bare IP is exact, not a prefix + ['10.8.0.0/24', '2001:db8::1'] // family mismatch never matches + ] + // G-A2: a whitelist miss used to answer `unknown`, which put "you are not + // where you declared you would be" in the same amber bucket as "nothing is + // configured at all". It is an observed deviation, so it has its own verdict. + for (const [cidr, ip] of outOfRange) { + it(`${ip} is outside ${cidr}`, () => expect(wl(cidr, ip)).toBe('off_profile')) + } + + // Was "an IPv6 CIDR matches only the literal prefix address (documented + // limitation)" — that limitation was the G-A5 defect and is now closed: + // `ip-match.ts` does real v6 prefix matching. Full v6 coverage lives in + // `ip-match.test.ts`; these two keep the option-level path honest. + it('an IPv6 CIDR matches the whole prefix, not just its literal address', () => { + expect(wl('2001:db8::1/64', '2001:db8::1')).toBe('safe') + expect(wl('2001:db8::1/64', '2001:db8::2')).toBe('safe') + }) + + it('an IPv6 address outside the prefix is still not safe', () => { + expect(wl('2001:db8:0:1::/64', '2001:db8:0:2::1')).toBe('off_profile') + }) + + it('a hit on any one entry of a multi-entry list is enough', () => { + expect(classifyIP('198.51.100.7', { whitelist: ['10.8.0.0/24', '198.51.100.0/24'], blacklist: [] })).toBe('safe') + }) + + it('blacklist ranges work the same way and still win over the whitelist', () => { + expect(classifyIP('10.8.0.9', { whitelist: ['10.8.0.0/24'], blacklist: ['10.8.0.0/28'] })).toBe('exposed') + expect(classifyIP('10.8.0.20', { whitelist: ['10.8.0.0/24'], blacklist: ['10.8.0.0/28'] })).toBe('safe') + }) +}) diff --git a/test/ip-monitor.test.ts b/test/ip-monitor.test.ts index 36d90301..e1b0a9d8 100644 --- a/test/ip-monitor.test.ts +++ b/test/ip-monitor.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { IPMonitor } from '../src/core/ip-monitor' +import { IPMonitor, classifyIP, hasListConflict, verdictAuthority, type IPSafety } from '../src/core/ip-monitor' // Drive check() directly rather than the timer — we're testing the settling // rule, not the schedule. @@ -95,13 +95,16 @@ describe('IPMonitor settling', () => { expect(m.status.ipSafety).toBe('exposed') }) - it('blacklist mode: IP not in blacklist is implicitly safe', async () => { + // G-A2: this used to answer plain `safe`. "Not obviously you" is an + // inference, not the verified statement "on the exit list you approved" — + // it now says so instead of borrowing a fact's solid green. + it('blacklist mode: IP not in blacklist is PRESUMED safe, not verified safe', async () => { const b = new IPMonitor() b.configure({ blacklist: ['203.0.113.0/24'], confirmations: 1, ipMode: 'http' }) mockIPs('198.51.100.5') await tick(b) expect(b.status.externalIP).toBe('198.51.100.5') - expect(b.status.ipSafety).toBe('safe') + expect(b.status.ipSafety).toBe('presumed_safe') }) it('honours a custom provider list so an operator can self-host', async () => { @@ -112,3 +115,407 @@ describe('IPMonitor settling', () => { expect(fetchMock.mock.calls[0][0]).toBe('https://ip.internal.example/json') }) }) + +// The verdict combination matrix (`docs/ALERT-ROLES.md` Part A.1). Four bits +// decide everything: whitelist configured, blacklist configured, and whether the +// address hits either. Nine reachable cells — table-driven so a new verdict +// (G-A2) is a column change, not a rewrite. +describe('classifyIP — the A-1..A-9 combination matrix', () => { + const WL = ['10.8.0.0/24'] // "my VPN" + const BL = ['1.2.3.4'] // "my home IP" + const VPN = '10.8.0.5' + const HOME = '1.2.3.4' + const NEITHER = '5.6.7.8' // VPN dropped, now on café NAT + + const cases: Array<[string, string, string[], string[], IPSafety]> = [ + ['A-1 no lists at all ', NEITHER, [], [], 'unknown'], + ['A-2 blacklist only, hit ', HOME, [], BL, 'exposed'], + ['A-3 blacklist only, miss ', NEITHER, [], BL, 'presumed_safe'], + ['A-4 whitelist only, hit ', VPN, WL, [], 'safe'], + ['A-5 whitelist only, miss ', NEITHER, WL, [], 'off_profile'], + ['A-6 both, hits both (config conflict) ', HOME, [...WL, HOME], BL, 'exposed'], + ['A-7 both, hits blacklist ', HOME, WL, BL, 'exposed'], + ['A-8 both, hits whitelist ', VPN, WL, BL, 'safe'], + ['A-9 both, hits NEITHER ', NEITHER, WL, BL, 'off_profile'] + ] + + for (const [name, ip, whitelist, blacklist, expected] of cases) { + it(`${name} → ${expected}`, () => { + expect(classifyIP(ip, { whitelist, blacklist })).toBe(expected) + }) + } + + // A-9 regression, stated as the scenario rather than the truth-table row. + // Before G-A1 this answered 'safe': the blacklist-only shortcut ran even + // though a whitelist was configured and had already failed to match. + it('G-A1: a dropped VPN with both lists set is never green', () => { + expect(classifyIP(NEITHER, { whitelist: WL, blacklist: BL })).not.toBe('safe') + }) + + it('declaring a whitelist means outside-it is never safe, whatever else is set', () => { + for (const blacklist of [[], BL, ['198.51.100.0/24']]) { + expect(classifyIP(NEITHER, { whitelist: WL, blacklist })).not.toBe('safe') + } + }) +}) + +describe('IPMonitor honours the fixed classification end-to-end', () => { + afterEach(() => vi.unstubAllGlobals()) + + // G-A1 stopped this answering `safe`; G-A2 stopped it answering `unknown`, + // which understated an observed deviation into the same amber bucket as + // "nothing is configured at all". + it('reports a whitelist miss as off_profile when a blacklist is also configured', async () => { + const m = new IPMonitor() + m.configure({ whitelist: ['10.8.0.0/24'], blacklist: ['1.2.3.4'], confirmations: 1, ipMode: 'http' }) + mockIPs('5.6.7.8') + await tick(m) + expect(m.status.externalIP).toBe('5.6.7.8') + expect(m.status.ipSafety).toBe('off_profile') + }) +}) + +// G-A3. A verdict that outlives the reading behind it is the same class of lie +// as the A-9 false green — and the moment it matters most is a VPN kill-switch, +// which drops the network and therefore the IP lookup at the same instant. +describe('IPMonitor staleness decay', () => { + const offline = (): void => { + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline') })) + } + + afterEach(() => vi.unstubAllGlobals()) + + function safeMonitor(staleAfter?: number): IPMonitor { + const m = new IPMonitor() + m.configure({ whitelist: ['10.8.0.0/24'], confirmations: 1, ipMode: 'http', staleAfter }) + return m + } + + it('a single failure does not flip the badge — transient blips are not news', async () => { + const m = safeMonitor() + mockIPs('10.8.0.5') + await tick(m) + offline() + await tick(m) + expect(m.status.ipSafety).toBe('safe') + expect(m.status.stale).toBe(false) + expect(m.status.consecutiveFailures).toBe(1) + }) + + it('decays the verdict to unknown once the failures reach the threshold', async () => { + const m = safeMonitor() + mockIPs('10.8.0.5') + await tick(m) + offline() + await tick(m) + expect(m.status.ipSafety).toBe('safe') // 1 of 2 — still holding + await tick(m) + expect(m.status.ipSafety).toBe('unknown') + expect(m.status.stale).toBe(true) + expect(m.status.consecutiveFailures).toBe(2) + }) + + it('keeps showing the last known address — the decay re-labels, it does not erase', async () => { + const m = safeMonitor(1) + mockIPs('10.8.0.5') + await tick(m) + offline() + await tick(m) + expect(m.status.stale).toBe(true) + expect(m.status.externalIP).toBe('10.8.0.5') + expect(m.status.error).toBeTruthy() + }) + + // The decay is deliberately uniform: holding a red alarm on a reading we can + // no longer confirm is the same dishonesty as holding a green one. + it('decays an exposed verdict too', async () => { + const m = new IPMonitor() + m.configure({ blacklist: ['203.0.113.0/24'], confirmations: 1, ipMode: 'http', staleAfter: 2 }) + mockIPs('203.0.113.9') + await tick(m) + expect(m.status.ipSafety).toBe('exposed') + offline() + await tick(m); await tick(m) + expect(m.status.ipSafety).toBe('unknown') + expect(m.status.stale).toBe(true) + }) + + it('a successful read clears the decay and re-classifies', async () => { + const m = safeMonitor(1) + mockIPs('10.8.0.5') + await tick(m) + offline() + await tick(m) + expect(m.status.stale).toBe(true) + mockIPs('10.8.0.5') + await tick(m) + expect(m.status.stale).toBe(false) + expect(m.status.consecutiveFailures).toBe(0) + expect(m.status.ipSafety).toBe('safe') + expect(m.status.error).toBeNull() + }) + + it('honours a custom staleAfter and ignores junk values', async () => { + const fast = safeMonitor(1) + mockIPs('10.8.0.5') + await tick(fast) + offline() + await tick(fast) + expect(fast.status.stale).toBe(true) + + const dflt = safeMonitor() + dflt.configure({ staleAfter: 0 }) // rejected → default 2 stands + mockIPs('10.8.0.5') + await tick(dflt) + offline() + await tick(dflt) + expect(dflt.status.stale).toBe(false) + await tick(dflt) + expect(dflt.status.stale).toBe(true) + }) + + // The threshold is deliberately tighter than `confirmations`: slow promotion + // is safe, slow expiry is not. + it('expires faster than it promotes', async () => { + const m = new IPMonitor() + m.configure({ whitelist: ['10.8.0.0/24'], ipMode: 'http' }) // both at defaults + mockIPs('10.8.0.5') + await tick(m) + offline() + await tick(m); await tick(m) + expect(m.status.stale).toBe(true) // 2 failures is enough to expire + }) + + // Same branch, second symptom: the verdict is a pure function of (address, + // lists), so a list edit must move the badge without waiting for the address + // to change. It used to sit on the old verdict indefinitely. + it('re-classifies on an unchanged address after the lists are edited', async () => { + const m = new IPMonitor() + m.configure({ whitelist: ['10.8.0.0/24'], confirmations: 1, ipMode: 'http' }) + mockIPs('10.8.0.5') + await tick(m) + expect(m.status.ipSafety).toBe('safe') + + m.configure({ whitelist: ['192.0.2.0/24'] }) // operator narrows the safe list + await tick(m) + expect(m.status.externalIP).toBe('10.8.0.5') + expect(m.status.ipSafety).toBe('off_profile') + }) + + it('settling does not count as a failure', async () => { + const m = new IPMonitor() + m.configure({ whitelist: ['10.8.0.0/24'], confirmations: 3, ipMode: 'http', staleAfter: 2 }) + mockIPs('10.8.0.5', '203.0.113.1', '203.0.113.1') + await tick(m) + await tick(m); await tick(m) + expect(m.status.settling).toBe(true) + expect(m.status.consecutiveFailures).toBe(0) + expect(m.status.stale).toBe(false) + }) +}) + +// G-A5. Before the shared matcher, `ipInCIDR` compared IPv6 by string equality +// against the network part, so a v6 whitelist could never match. That dropped a +// v6 egress into A-5 (whitelist-only) or — with a v4-only blacklist — into the +// A-3 fall-through, which answers green. The A-9 fix alone did not reach v6. +describe('IPMonitor over IPv6', () => { + afterEach(() => vi.unstubAllGlobals()) + + function v6Monitor(opts: Record): IPMonitor { + const m = new IPMonitor() + m.configure({ confirmations: 1, ipMode: 'http', ...opts }) + return m + } + + it('a v6 whitelist actually matches now', async () => { + const m = v6Monitor({ whitelist: ['2001:db8::/32'] }) + mockIPs('2001:db8:0:1::5') + await tick(m) + expect(m.status.ipSafety).toBe('safe') + }) + + it('a v6 blacklist hit is exposed', async () => { + const m = v6Monitor({ blacklist: ['2001:db8:dead::/48'] }) + mockIPs('2001:db8:dead:beef::1') + await tick(m) + expect(m.status.ipSafety).toBe('exposed') + }) + + it('outside a v6 whitelist is not safe — A-5 over v6', async () => { + const m = v6Monitor({ whitelist: ['2001:db8::/32'] }) + mockIPs('2001:db9::1') + await tick(m) + expect(m.status.ipSafety).toBe('off_profile') + }) + + // A-9 over v6: both lists set, the address hits neither. + it('A-9 over v6 is not green either', async () => { + const m = v6Monitor({ whitelist: ['2001:db8::/32'], blacklist: ['2001:dead::/32'] }) + mockIPs('2001:beef::1') + await tick(m) + expect(m.status.ipSafety).toBe('off_profile') + }) + + // The old family guard returned false on a mismatch, so a v6 egress against a + // v4-only blacklist took the A-3 fall-through and reported green. + it('a v6 egress against a v4-only whitelist is not green', async () => { + const m = v6Monitor({ whitelist: ['10.8.0.0/24'] }) + mockIPs('2001:db8::1') + await tick(m) + expect(m.status.ipSafety).toBe('off_profile') + }) + + it('an IPv4-mapped reading still matches the operator\'s v4 whitelist', async () => { + const m = v6Monitor({ whitelist: ['10.8.0.0/24'] }) + mockIPs('::ffff:10.8.0.5') + await tick(m) + expect(m.status.ipSafety).toBe('safe') + }) + + it('an equivalent v6 notation in the list still matches', async () => { + const m = v6Monitor({ whitelist: ['2001:0db8:0000:0000:0000:0000:0000:0001'] }) + mockIPs('2001:db8::1') + await tick(m) + expect(m.status.ipSafety).toBe('safe') + }) +}) + +// G-A2. Three states could not encode nine cells, so two of them lied: +// `presumed_safe` was reported as `safe` (an inference wearing a fact's solid +// green) and `off_profile` as `unknown` (an observed deviation filed as missing +// information). RedLog never blocks, so the badge IS the intervention — an +// understated verdict is not a cosmetic problem. +describe('the five verdicts', () => { + const WL = ['10.8.0.0/24'] + const BL = ['1.2.3.4'] + + it('separates a verified exit from a merely un-incriminating one', () => { + expect(classifyIP('10.8.0.5', { whitelist: WL, blacklist: [] })).toBe('safe') + expect(classifyIP('5.6.7.8', { whitelist: [], blacklist: BL })).toBe('presumed_safe') + }) + + it('separates an observed deviation from having no information', () => { + expect(classifyIP('5.6.7.8', { whitelist: WL, blacklist: [] })).toBe('off_profile') + expect(classifyIP('5.6.7.8', { whitelist: [], blacklist: [] })).toBe('unknown') + }) + + it('a blacklist hit still outranks everything', () => { + expect(classifyIP('1.2.3.4', { whitelist: WL, blacklist: BL })).toBe('exposed') + expect(classifyIP('1.2.3.4', { whitelist: ['1.2.3.4'], blacklist: BL })).toBe('exposed') + }) + + it('carries the §3 tier so no surface has to re-derive it from the name', () => { + expect(verdictAuthority('safe')).toBe('fact') + expect(verdictAuthority('off_profile')).toBe('fact') + expect(verdictAuthority('exposed')).toBe('fact') + // The whole reason this verdict exists. + expect(verdictAuthority('presumed_safe')).toBe('inferred') + // `unknown` asserts nothing, so it makes no claim to tier. + expect(verdictAuthority('unknown')).toBeNull() + }) +}) + +// A-6: the verdict is `exposed` and correct, but a red badge looks like every +// other red badge — the operator would never learn their config contradicts +// itself. Reported alongside the verdict, not as one: it says something about +// the CONFIG, not about where the operator is. +describe('A-6 list conflict', () => { + const both = { whitelist: ['203.0.113.0/24'], blacklist: ['203.0.113.0/24'] } + + it('flags an address that matches both lists', () => { + expect(hasListConflict('203.0.113.9', both)).toBe(true) + expect(classifyIP('203.0.113.9', both)).toBe('exposed') + }) + + it('does not flag an address that matches only one', () => { + expect(hasListConflict('10.8.0.5', { whitelist: ['10.8.0.0/24'], blacklist: ['1.2.3.4'] })).toBe(false) + expect(hasListConflict('1.2.3.4', { whitelist: ['10.8.0.0/24'], blacklist: ['1.2.3.4'] })).toBe(false) + }) + + it('reaches the status the UI reads', async () => { + const m = new IPMonitor() + m.configure({ ...both, confirmations: 1, ipMode: 'http' }) + mockIPs('203.0.113.9') + await tick(m) + expect(m.status.ipSafety).toBe('exposed') + expect(m.status.listConflict).toBe(true) + }) + + it('clears once the address stops matching both', async () => { + const m = new IPMonitor() + m.configure({ ...both, confirmations: 1, ipMode: 'http' }) + mockIPs('203.0.113.9') + await tick(m) + expect(m.status.listConflict).toBe(true) + mockIPs('198.51.100.5') + await tick(m) + expect(m.status.listConflict).toBe(false) + }) +}) + +// G-A4. `internalIP` was collected, displayed, and never judged — so a laptop +// that silently reassociated to a guest SSID mid-engagement read exactly like +// one still on the client VLAN. The external verdict cannot catch it: the +// egress can be perfectly fine while you are on the wrong network. +describe('LAN profile — the internal address gets a verdict', () => { + afterEach(() => vi.unstubAllGlobals()) + + // No new vocabulary: the same classifier with the profile as the whitelist + // and no blacklist, so only three of the nine cells are reachable. + it('reuses the verdict machinery rather than inventing a LAN one', () => { + const onVlan = { whitelist: ['10.10.20.0/24'], blacklist: [] } + expect(classifyIP('10.10.20.55', onVlan)).toBe('safe') + expect(classifyIP('192.168.1.55', onVlan)).toBe('off_profile') + expect(classifyIP('192.168.1.55', { whitelist: [], blacklist: [] })).toBe('unknown') + }) + + it('an unconfigured profile claims nothing', async () => { + const m = new IPMonitor() + m.configure({ confirmations: 1, ipMode: 'http' }) + mockIPs('203.0.113.9') + await tick(m) + expect(m.status.lanSafety).toBe('unknown') + }) + + it('judges the internal address independently of the external one', async () => { + const m = new IPMonitor() + // Egress verified fine; the LAN is where the problem is. + m.configure({ whitelist: ['203.0.113.0/24'], lanProfile: ['10.10.20.0/24'], confirmations: 1, ipMode: 'http' }) + mockIPs('203.0.113.9') + await tick(m) + expect(m.status.ipSafety).toBe('safe') + expect(['safe', 'off_profile']).toContain(m.status.lanSafety) + }) + + it('re-derives on every poll, so editing the profile moves the verdict', async () => { + const m = new IPMonitor() + m.configure({ confirmations: 1, ipMode: 'http' }) + mockIPs('203.0.113.9') + await tick(m) + const internal = m.status.internalIP + expect(m.status.lanSafety).toBe('unknown') + + // Declare a profile that cannot match whatever this machine is on. + m.configure({ lanProfile: ['198.51.100.0/24'] }) + await tick(m) + expect(m.status.lanSafety).toBe(internal ? 'off_profile' : 'unknown') + }) + + // The internal address is a LOCAL read of the network interfaces. Losing it + // because the INTERNET died is exactly backwards: dropping off the client + // VLAN is more likely when the network is misbehaving. + it('survives a failed external lookup — nothing about it expired', async () => { + const m = new IPMonitor() + m.configure({ lanProfile: ['198.51.100.0/24'], confirmations: 1, staleAfter: 1, ipMode: 'http' }) + mockIPs('203.0.113.9') + await tick(m) + const before = { ip: m.status.internalIP, lan: m.status.lanSafety } + + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline') })) + await tick(m) + expect(m.status.stale).toBe(true) + expect(m.status.ipSafety).toBe('unknown') // the EXTERNAL verdict expired + expect(m.status.internalIP).toBe(before.ip) // the local read did not + expect(m.status.lanSafety).toBe(before.lan) + }) +}) diff --git a/test/ip-stale-display.test.tsx b/test/ip-stale-display.test.tsx new file mode 100644 index 00000000..2a1ce483 --- /dev/null +++ b/test/ip-stale-display.test.tsx @@ -0,0 +1,171 @@ +// @vitest-environment jsdom +// +// G-A3, the last hop: the core decays a verdict once its reading expires, but +// until now `settling`/`stale` were not even DECLARED on the renderer's +// IPStatus, so no surface could show them — a badge could sit on green while +// the reading behind it was 40 seconds dead. Since RedLog never blocks, a +// verdict that fails to reach the operator's eye is the whole defence failing, +// so the decay gets an assertion at the pixel, not just in the state machine. +// +// (Kept separate from `alert-display.test.tsx`, which covers the safe/exposed/ +// flash path, so the two files stay independently editable.) + +import { describe, it, expect, afterEach } from 'vitest' +import { render, cleanup, screen } from '@testing-library/react' +import { I18nProvider } from '../src/renderer/src/i18n' +import IPStatusCard from '../src/renderer/src/components/IPStatusCard' + +const unsub = (): void => {} + +function installBridge(status: Record): void { + ;(window as unknown as { redlog: unknown }).redlog = { + platform: 'darwin', + ip: { getStatus: async () => status, onStatus: () => unsub }, + pivots: { getActive: async () => [], onChange: () => unsub } + } +} + +const BASE = { + externalIP: '10.8.0.5', + internalIP: '10.0.0.2', + ipSafety: 'safe' as 'safe' | 'presumed_safe' | 'off_profile' | 'exposed' | 'unknown', + lastCheck: 1_700_000_000_000, + error: null as string | null, + settling: false, + consecutiveFailures: 0, + stale: false, + listConflict: false, + lanSafety: 'unknown' as 'safe' | 'off_profile' | 'unknown' +} + +async function renderCard(over: Partial = {}): Promise { + installBridge({ ...BASE, ...over }) + render( + + + + ) + await screen.findByText('10.8.0.5') +} + +describe('IPStatusCard — expired and unconfirmed verdicts reach the eye', () => { + afterEach(cleanup) + + it('a live safe verdict still reads as Safe IP', async () => { + await renderCard() + expect(screen.getByText('Safe IP')).toBeTruthy() + expect(screen.queryByText(/No current reading/i)).toBeNull() + }) + + it('a decayed verdict does NOT read as safe — it says there is no current reading', async () => { + await renderCard({ ipSafety: 'unknown', stale: true, consecutiveFailures: 3, error: 'offline' }) + expect(screen.queryByText('Safe IP')).toBeNull() + expect(screen.getByText('No current reading')).toBeTruthy() + }) + + it('the stale hint names the failure count and the kill-switch case', async () => { + await renderCard({ ipSafety: 'unknown', stale: true, consecutiveFailures: 4, error: 'offline' }) + expect(screen.getByText(/4 consecutive checks/)).toBeTruthy() + expect(screen.getByText(/kill-switch/)).toBeTruthy() + }) + + // A decayed verdict is also 'unknown', but telling the operator to go + // configure lists they already configured is the wrong advice for a dead link. + it('shows the stale hint instead of the "configure your lists" hint', async () => { + await renderCard({ ipSafety: 'unknown', stale: true, consecutiveFailures: 3 }) + expect(screen.queryByText(/Settings ▸ Network/)).toBeNull() + }) + + it('still shows the "configure your lists" hint for a genuinely unclassified address', async () => { + await renderCard({ ipSafety: 'unknown' }) + expect(screen.getByText(/Settings ▸ Network/)).toBeTruthy() + }) + + it('keeps the last known address on screen — the decay re-labels, it does not erase', async () => { + await renderCard({ ipSafety: 'unknown', stale: true, consecutiveFailures: 3 }) + expect(screen.getByText('10.8.0.5')).toBeTruthy() + }) + + it('an unconfirmed address is qualified without being called dead', async () => { + await renderCard({ settling: true }) + expect(screen.getByText('Safe IP')).toBeTruthy() + expect(screen.getByText(/being confirmed/)).toBeTruthy() + expect(screen.queryByText('No current reading')).toBeNull() + }) +}) + +// G-A2 at the pixel. The claim of this ticket is that an inference must not +// look like a fact and an observed deviation must not look like missing +// information — both are claims about what the operator SEES, so neither is +// proved by a unit test on the verdict alone. +describe('IPStatusCard — the five verdicts are distinguishable', () => { + afterEach(cleanup) + + it('a verified exit reads as Safe IP with no caveat', async () => { + await renderCard({ ipSafety: 'safe' }) + expect(screen.getByText('Safe IP')).toBeTruthy() + expect(screen.queryByText(/inference/i)).toBeNull() + }) + + it('an inferred exit does NOT read as Safe IP', async () => { + await renderCard({ ipSafety: 'presumed_safe' }) + expect(screen.queryByText('Safe IP')).toBeNull() + expect(screen.getByText('Presumed safe')).toBeTruthy() + expect(screen.getByText(/nothing to confirm it against/)).toBeTruthy() + }) + + it('a whitelist miss reads as a deviation, not as missing information', async () => { + await renderCard({ ipSafety: 'off_profile' }) + expect(screen.getByText('Off-profile IP')).toBeTruthy() + // The old behaviour: same amber "go configure your lists" advice as a + // completely unconfigured RedLog. The lists ARE configured — that is the + // whole point of the verdict. + expect(screen.queryByText(/Settings ▸ Network to enable classification/)).toBeNull() + expect(screen.getByText(/not where you declared/)).toBeTruthy() + }) + + it('an unconfigured RedLog still gets the configure-your-lists advice', async () => { + await renderCard({ ipSafety: 'unknown' }) + expect(screen.getByText(/Settings ▸ Network/)).toBeTruthy() + }) + + it('surfaces a contradictory config without changing the verdict', async () => { + await renderCard({ ipSafety: 'exposed', listConflict: true }) + expect(screen.getByText('Exposed IP')).toBeTruthy() + expect(screen.getByText(/BOTH your Safe IP and Exposed IP lists/)).toBeTruthy() + }) + + it('says nothing about lists when they do not contradict', async () => { + await renderCard({ ipSafety: 'exposed', listConflict: false }) + expect(screen.queryByText(/BOTH your Safe IP/)).toBeNull() + }) +}) + +// G-A4 at the pixel: the internal address used to be inert text. +describe('IPStatusCard — the internal address is judged too', () => { + afterEach(cleanup) + + it('says nothing when no LAN profile is declared', async () => { + await renderCard({ lanSafety: 'unknown' }) + expect(screen.getByText('10.0.0.2')).toBeTruthy() + expect(screen.queryByText(/wrong network/)).toBeNull() + }) + + it('flags an internal address off the expected segment', async () => { + await renderCard({ lanSafety: 'off_profile' }) + expect(screen.getByText(/not on any of the LAN segments/)).toBeTruthy() + expect(screen.getByText(/guest SSID/)).toBeTruthy() + }) + + // The point of the whole ticket: the external verdict cannot catch this. + it('an off-profile LAN is flagged even while the egress reads safe', async () => { + await renderCard({ ipSafety: 'safe', lanSafety: 'off_profile' }) + expect(screen.getByText('Safe IP')).toBeTruthy() + expect(screen.getByText(/wrong network/)).toBeTruthy() + }) + + it('says nothing when the internal address is where it should be', async () => { + await renderCard({ lanSafety: 'safe' }) + expect(screen.queryByText(/wrong network/)).toBeNull() + }) +}) diff --git a/test/lane-visibility.test.ts b/test/lane-visibility.test.ts new file mode 100644 index 00000000..4239321b --- /dev/null +++ b/test/lane-visibility.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest' +import { populatedLanes, visibleLanes, soloLaneOf } from '../src/renderer/src/lib/laneVisibility' + +// Pins the lane-visibility rules extracted from Timeline.tsx so the axis refactor +// can lean on them without a behaviour change. The canonical order used in the +// component is the LANES tuple; here a small representative slice stands in. +const LANES = ['shell', 'agent', 'scanner', 'dns', 'pivot', 'loot', 'system'] as const +type Ev = { agentType: string } +const laneOf = (e: Ev): string => e.agentType + +describe('populatedLanes', () => { + it('is empty for no events', () => { + expect(populatedLanes([], laneOf).size).toBe(0) + }) + + it('collects the distinct lanes that have events', () => { + const p = populatedLanes([{ agentType: 'shell' }, { agentType: 'shell' }, { agentType: 'dns' }], laneOf) + expect([...p].sort()).toEqual(['dns', 'shell']) + }) +}) + +describe('visibleLanes', () => { + it('keeps only populated, non-hidden lanes in canonical order', () => { + // populated out of canonical order on purpose — output must follow LANES. + const populated = new Set(['loot', 'shell', 'dns']) + const hidden = new Set() + expect(visibleLanes(LANES, populated, hidden)).toEqual(['shell', 'dns', 'loot']) + }) + + it('drops hidden lanes', () => { + const populated = new Set(['shell', 'dns', 'loot']) + const hidden = new Set(['dns']) + expect(visibleLanes(LANES, populated, hidden)).toEqual(['shell', 'loot']) + }) + + it('excludes lanes that are populated but not in the canonical list', () => { + const populated = new Set(['shell', 'ghost-lane']) + expect(visibleLanes(LANES, populated, new Set())).toEqual(['shell']) + }) + + it('is empty when everything populated is hidden', () => { + const populated = new Set(['shell', 'dns']) + const hidden = new Set(['shell', 'dns']) + expect(visibleLanes(LANES, populated, hidden)).toEqual([]) + }) +}) + +describe('soloLaneOf', () => { + it('is the lone visible lane only when others are explicitly hidden', () => { + expect(soloLaneOf(['shell'], new Set(['dns', 'loot']))).toBe('shell') + }) + + it('is null when one lane is visible but nothing was hidden (naturally single-lane)', () => { + // The key distinction: a single-lane engagement is NOT a solo — hidden must + // be non-empty. Mirrors `visibleLanes.length === 1 && hiddenLanes.size > 0`. + expect(soloLaneOf(['shell'], new Set())).toBeNull() + }) + + it('is null when more than one lane is visible', () => { + expect(soloLaneOf(['shell', 'dns'], new Set(['loot']))).toBeNull() + }) + + it('is null when no lane is visible', () => { + expect(soloLaneOf([], new Set(['shell']))).toBeNull() + }) +}) diff --git a/test/phase-inference.test.ts b/test/phase-inference.test.ts new file mode 100644 index 00000000..d3592628 --- /dev/null +++ b/test/phase-inference.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect } from 'vitest' +import { + inferPhaseSuggestions, + type EventLike, + type InferredPhase +} from '../src/renderer/src/lib/phaseInference' + +// This is the DESIGN-PRINCIPLES §3 SUGGESTION layer — never authoritative. +// Every property asserted here is a promise the seam makes to the operator: +// the output is `inferred`, confidence-scored, and carries a sourceEventId so a +// single click can promote a suggestion into an authoritative marker. These +// tests pin the conservative event-type → phase mapping and the boundary logic +// (adjacent de-dup, sort-before-process), not any ATT&CK opinion. + +// Tiny builder so each case reads as "an event of this type, at this ts". +let seq = 0 +function ev(agentType: string, ts: number, data?: EventLike['data']): EventLike { + return { id: `e${seq++}`, timestamp: ts, agentType, data } +} + +describe('inferPhaseSuggestions — mapping table', () => { + it('maps pivot → lateral-movement, high', () => { + const [s] = inferPhaseSuggestions([ev('pivot', 10)]) + expect(s.phase).toBe('lateral-movement') + expect(s.confidence).toBe('high') + expect(s.reason).toBe('pivot/tunnel detected') + }) + + it('maps file_transfer with exfil direction → exfil, medium', () => { + const [s] = inferPhaseSuggestions([ev('file_transfer', 10, { direction: 'exfil' })]) + expect(s.phase).toBe('exfil') + expect(s.confidence).toBe('medium') + }) + + it('maps file_transfer with exfil subtype → exfil, medium', () => { + const [s] = inferPhaseSuggestions([ev('file_transfer', 10, { subtype: 'exfil' })]) + expect(s.phase).toBe('exfil') + expect(s.confidence).toBe('medium') + }) + + it('maps ingress file_transfer → delivery, low', () => { + const [s] = inferPhaseSuggestions([ev('file_transfer', 10, { direction: 'ingress' })]) + expect(s.phase).toBe('delivery') + expect(s.confidence).toBe('low') + }) + + it('treats a file_transfer with no direction as delivery (conservative default)', () => { + const [s] = inferPhaseSuggestions([ev('file_transfer', 10)]) + expect(s.phase).toBe('delivery') + expect(s.confidence).toBe('low') + }) + + it('maps cleanup → anti-forensics, high', () => { + const [s] = inferPhaseSuggestions([ev('cleanup', 10)]) + expect(s.phase).toBe('anti-forensics') + expect(s.confidence).toBe('high') + }) + + it('maps credential_use → credential-access, medium', () => { + const [s] = inferPhaseSuggestions([ev('credential_use', 10)]) + expect(s.phase).toBe('credential-access') + expect(s.confidence).toBe('medium') + }) + + it('maps loot → credential-access, medium', () => { + const [s] = inferPhaseSuggestions([ev('loot', 10)]) + expect(s.phase).toBe('credential-access') + expect(s.confidence).toBe('medium') + }) + + it('maps scanner / dns / http_navigation → recon, low', () => { + for (const t of ['scanner', 'dns', 'http_navigation']) { + const [s] = inferPhaseSuggestions([ev(t, 10)]) + expect(s.phase).toBe('recon') + expect(s.confidence).toBe('low') + } + }) + + it('produces no suggestion for unmapped event types', () => { + expect(inferPhaseSuggestions([ev('marker', 10)])).toEqual([]) + expect(inferPhaseSuggestions([ev('screenshot', 10)])).toEqual([]) + expect(inferPhaseSuggestions([ev('system', 10)])).toEqual([]) + }) +}) + +describe('inferPhaseSuggestions — boundary logic', () => { + it('returns [] for empty input', () => { + expect(inferPhaseSuggestions([])).toEqual([]) + }) + + it('collapses adjacent duplicate phases to the first transition point only', () => { + // Three recon-ish events in a row are one recon boundary, not three. We mark + // where the phase STARTS, not every event inside it. + const out = inferPhaseSuggestions([ + ev('dns', 10), + ev('scanner', 20), + ev('http_navigation', 30), + ev('pivot', 40) + ]) + expect(out.map((s) => s.phase)).toEqual(['recon', 'lateral-movement']) + // the recon boundary is the first (dns) event, at ts 10 + expect(out[0].ts).toBe(10) + }) + + it('re-enters a phase after leaving it (non-adjacent repeats are kept)', () => { + const out = inferPhaseSuggestions([ + ev('dns', 10), // recon + ev('pivot', 20), // lateral-movement + ev('scanner', 30) // recon again — a new boundary, not a duplicate + ]) + expect(out.map((s) => s.phase)).toEqual(['recon', 'lateral-movement', 'recon']) + }) + + it('sorts unsorted input by ts before processing', () => { + const out = inferPhaseSuggestions([ + ev('pivot', 40), + ev('dns', 10), + ev('scanner', 20), + ev('cleanup', 50) + ]) + expect(out.map((s) => s.phase)).toEqual(['recon', 'lateral-movement', 'anti-forensics']) + expect(out.map((s) => s.ts)).toEqual([10, 40, 50]) + }) + + it('orders a multi-phase engagement by ts', () => { + const out = inferPhaseSuggestions([ + ev('dns', 10), + ev('credential_use', 20), + ev('pivot', 30), + ev('file_transfer', 40, { direction: 'exfil' }), + ev('cleanup', 50) + ]) + expect(out.map((s) => s.phase)).toEqual([ + 'recon', + 'credential-access', + 'lateral-movement', + 'exfil', + 'anti-forensics' + ]) + }) +}) + +describe('inferPhaseSuggestions — suggestion-layer contract', () => { + it('carries the triggering event id as sourceEventId', () => { + const dns = ev('dns', 10) + const pivot = ev('pivot', 20) + const out = inferPhaseSuggestions([dns, pivot]) + expect(out[0].sourceEventId).toBe(dns.id) + expect(out[1].sourceEventId).toBe(pivot.id) + }) + + it('sourceEventId of a collapsed run points at the first event of the run', () => { + const first = ev('dns', 10) + const second = ev('scanner', 20) + const out = inferPhaseSuggestions([first, second]) + expect(out).toHaveLength(1) + expect(out[0].sourceEventId).toBe(first.id) + }) + + it('every suggestion is confidence-scored with a valid tier', () => { + const out: InferredPhase[] = inferPhaseSuggestions([ + ev('pivot', 10), + ev('loot', 20), + ev('dns', 30) + ]) + for (const s of out) { + expect(['low', 'medium', 'high']).toContain(s.confidence) + } + }) +}) diff --git a/test/phase-segments.test.ts b/test/phase-segments.test.ts new file mode 100644 index 00000000..6be40038 --- /dev/null +++ b/test/phase-segments.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from 'vitest' +import { phaseSegments, phaseMarkersFromEvents } from '../src/renderer/src/lib/phaseSegments' +import type { PhaseMarker, MarkerEventLike } from '../src/renderer/src/lib/phaseSegments' + +// DESIGN-PRINCIPLES §3 (two-tier attributes) draws the line: an operator marker is +// an AUTHORITATIVE assertion, an inferred detection is only a suggestion. §11 splits +// phase reconstruction along that same line — this seam is the "乙" half, the +// authoritative one: phase segmentation derived *purely* from the operator's own +// phase-change markers (each marker is one on-chain, attributable claim). The +// auto-inference half (dashed suggestions) is a different seam and is not tested here. +// +// Two pure functions, so the whole derivation is a value → value transform with no +// clock, no store and no React: phaseMarkersFromEvents (raw events → the phase-change +// markers) and phaseSegments (markers → contiguous, non-overlapping segments). + +const M = (ts: number, phase: string, markerId: string): PhaseMarker => ({ ts, phase, markerId }) + +describe('phaseSegments', () => { + it('returns nothing for no markers — no operator claim, no authoritative phase', () => { + // The whole point of this half is that phase is only asserted by the operator. + // With zero markers there is simply no authoritative segmentation to draw. + expect(phaseSegments([])).toEqual([]) + }) + + it('a single marker opens one open-ended segment when no domain end is known', () => { + // One claim, still in force: it runs from its own ts to "now / unknown", which we + // model as end=null (an in-progress phase) rather than inventing an end. + expect(phaseSegments([M(100, 'recon', 'a')])).toEqual([ + { phase: 'recon', start: 100, end: null, markerId: 'a' } + ]) + }) + + it('a single marker is closed at domainEnd when the domain end is provided', () => { + // Given the engagement's known end (e.g. last event ts), the final phase is bounded + // there instead of dangling — this is what lets the rendered band have a right edge. + expect(phaseSegments([M(100, 'recon', 'a')], 500)).toEqual([ + { phase: 'recon', start: 100, end: 500, markerId: 'a' } + ]) + }) + + it('multiple markers cut back-to-back segments, last one open-ended', () => { + // Each marker opens a segment; its end is the NEXT marker's ts, so the segments are + // contiguous and non-overlapping. The last one is left open (end=null) with no domainEnd. + expect(phaseSegments([M(0, 'recon', 'a'), M(10, 'exploit', 'b'), M(25, 'exfil', 'c')])).toEqual([ + { phase: 'recon', start: 0, end: 10, markerId: 'a' }, + { phase: 'exploit', start: 10, end: 25, markerId: 'b' }, + { phase: 'exfil', start: 25, end: null, markerId: 'c' } + ]) + }) + + it('closes the final segment at domainEnd when provided', () => { + expect(phaseSegments([M(0, 'recon', 'a'), M(10, 'exploit', 'b')], 40)).toEqual([ + { phase: 'recon', start: 0, end: 10, markerId: 'a' }, + { phase: 'exploit', start: 10, end: 40, markerId: 'b' } + ]) + }) + + it('sorts unordered marker input by ts before segmenting', () => { + // Callers may hand us markers in event-store order, not time order. Segmentation is + // a statement about time, so we sort first; the output is always chronological. + expect(phaseSegments([M(25, 'exfil', 'c'), M(0, 'recon', 'a'), M(10, 'exploit', 'b')])).toEqual([ + { phase: 'recon', start: 0, end: 10, markerId: 'a' }, + { phase: 'exploit', start: 10, end: 25, markerId: 'b' }, + { phase: 'exfil', start: 25, end: null, markerId: 'c' } + ]) + }) + + it('does NOT merge adjacent markers that name the same phase', () => { + // Each marker is a distinct operator act — re-asserting "recon" is a separate, + // separately-attributable claim (its own markerId, its own on-chain event). Merging + // them would erase that the operator marked the boundary twice, so we keep both. + expect(phaseSegments([M(0, 'recon', 'a'), M(10, 'recon', 'b')])).toEqual([ + { phase: 'recon', start: 0, end: 10, markerId: 'a' }, + { phase: 'recon', start: 10, end: null, markerId: 'b' } + ]) + }) +}) + +describe('phaseMarkersFromEvents', () => { + const ev = ( + id: string, + timestamp: number, + agentType: string, + data?: MarkerEventLike['data'] + ): MarkerEventLike => ({ id, timestamp, agentType, data }) + + it('picks only marker events whose data signals a phase change', () => { + // Three independent ways an event says "this is a phase change": an explicit + // subtype, a phase field, or a category. Any one qualifies. + const events: MarkerEventLike[] = [ + ev('1', 10, 'marker', { subtype: 'phase', title: 'recon' }), + ev('2', 20, 'marker', { phase: 'exploit' }), + ev('3', 30, 'marker', { category: 'phase', title: 'exfil' }) + ] + expect(phaseMarkersFromEvents(events)).toEqual([ + { ts: 10, phase: 'recon', markerId: '1' }, + { ts: 20, phase: 'exploit', markerId: '2' }, + { ts: 30, phase: 'exfil', markerId: '3' } + ]) + }) + + it('ignores non-marker events and markers that are not phase changes', () => { + // A shell command, and a plain finding-note marker (no phase signal) must not + // become phase boundaries — only deliberate phase markers segment the timeline. + const events: MarkerEventLike[] = [ + ev('s', 5, 'shell', { subtype: 'command_start' }), + ev('note', 15, 'marker', { subtype: 'finding', title: 'Found an IDOR' }), + ev('p', 25, 'marker', { subtype: 'phase', title: 'recon' }) + ] + expect(phaseMarkersFromEvents(events)).toEqual([{ ts: 25, phase: 'recon', markerId: 'p' }]) + }) + + it('prefers data.phase for the phase string and falls back to data.title', () => { + // data.phase is the canonical field; a phase marker created through the generic + // marker UI may only carry a title, so title is the fallback name. + const events: MarkerEventLike[] = [ + ev('a', 10, 'marker', { subtype: 'phase', phase: 'exploit', title: 'ignore me' }), + ev('b', 20, 'marker', { subtype: 'phase', title: 'lateral-movement' }) + ] + expect(phaseMarkersFromEvents(events)).toEqual([ + { ts: 10, phase: 'exploit', markerId: 'a' }, + { ts: 20, phase: 'lateral-movement', markerId: 'b' } + ]) + }) + + it('returns markers sorted by ts even when events arrive out of order', () => { + const events: MarkerEventLike[] = [ + ev('late', 30, 'marker', { phase: 'exfil' }), + ev('early', 10, 'marker', { phase: 'recon' }) + ] + expect(phaseMarkersFromEvents(events)).toEqual([ + { ts: 10, phase: 'recon', markerId: 'early' }, + { ts: 30, phase: 'exfil', markerId: 'late' } + ]) + }) +}) diff --git a/test/process-monitor-cadence.test.ts b/test/process-monitor-cadence.test.ts new file mode 100644 index 00000000..0397d454 --- /dev/null +++ b/test/process-monitor-cadence.test.ts @@ -0,0 +1,135 @@ +// processMonitor.pollMs — the poll cadence, and the platform floors that stop a +// too-eager setting from stacking `ps` calls on top of each other. +// +// Previously manual-only (G-UI1) on the assumption that this needed a live +// poller. It does not: the schedule is decided synchronously in +// `startProcessMonitor`, so spying the timer is enough — and the Windows floor +// in particular is exactly the kind of platform-conditional constant that never +// gets exercised on the maintainer's machine. + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' + +vi.mock('child_process', () => ({ + // Answer the seed scan with no processes; the cadence is what is under test. + execFile: (_f: string, _a: string[], _o: unknown, cb: (e: Error | null, out: string) => void) => cb(null, '') +})) +vi.mock('../src/core/db/events', () => ({ insertEvent: () => null })) + +const { startProcessMonitor, stopProcessMonitor } = await import('../src/main/services/process-monitor') + +const ATTRIBUTION = { enabled: true, engagementId: 'e', operatorId: 'op' } + +/** Platform is read at schedule time, so it can be swapped per test. */ +function asPlatform(p: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: p, configurable: true }) +} +const REAL_PLATFORM = process.platform + +/** The interval `startProcessMonitor` schedules, in ms — null if it declined. + * + * `pollMs: undefined` is passed explicitly because the monitor merges a partial + * over module-level config: without it, a `pollMs` set by an earlier test leaks + * into the next one and "the default" silently becomes "whatever ran before". */ +function scheduled(opts: Record = {}): number | null { + const spy = vi.spyOn(globalThis, 'setInterval') + startProcessMonitor({ ...ATTRIBUTION, pollMs: undefined, ...opts }) + const call = spy.mock.calls[spy.mock.calls.length - 1] + spy.mockRestore() + return call ? (call[1] as number) : null +} + +beforeEach(() => stopProcessMonitor()) +afterEach(() => { + stopProcessMonitor() + asPlatform(REAL_PLATFORM) + vi.restoreAllMocks() +}) + +describe('pollMs on macOS / Linux', () => { + beforeEach(() => asPlatform('darwin')) + + it('defaults to 500 ms', () => { + expect(scheduled({})).toBe(500) + }) + + it('honours a slower cadence verbatim', () => { + expect(scheduled({ pollMs: 5000 })).toBe(5000) + }) + + it('honours a faster one down to the 200 ms floor', () => { + expect(scheduled({ pollMs: 250 })).toBe(250) + expect(scheduled({ pollMs: 200 })).toBe(200) + }) + + it('floors anything below 200 ms — polling faster than `ps` returns just queues work', () => { + expect(scheduled({ pollMs: 10 })).toBe(200) + // 0 is a real value here (`??` only fills in null/undefined), so it lands on + // the floor rather than on the default. + expect(scheduled({ pollMs: 0 })).toBe(200) + }) + + it('linux behaves like darwin', () => { + asPlatform('linux') + expect(scheduled({})).toBe(500) + expect(scheduled({ pollMs: 50 })).toBe(200) + }) +}) + +describe('pollMs on Windows', () => { + beforeEach(() => asPlatform('win32')) + + it('defaults to 2000 ms — a cold PowerShell spawn is 800-1500 ms', () => { + expect(scheduled({})).toBe(2000) + }) + + it('floors at 2000 ms, so the 500 ms cross-platform default cannot stack calls', () => { + expect(scheduled({ pollMs: 500 })).toBe(2000) + expect(scheduled({ pollMs: 100 })).toBe(2000) + }) + + it('a deliberately slower cadence is still honoured', () => { + expect(scheduled({ pollMs: 10_000 })).toBe(10_000) + }) +}) + +describe('when the monitor declines to run at all', () => { + beforeEach(() => asPlatform('darwin')) + + it('disabled (the default) schedules nothing', () => { + expect(scheduled({ enabled: false })).toBeNull() + }) + + it('no engagement id — every event needs attribution', () => { + expect(scheduled({ engagementId: '' })).toBeNull() + }) + + it('no operator id', () => { + expect(scheduled({ operatorId: '' })).toBeNull() + }) + + it('an unsupported platform is skipped rather than half-started', () => { + asPlatform('freebsd') + expect(scheduled({})).toBeNull() + }) +}) + +describe('restart semantics', () => { + beforeEach(() => asPlatform('darwin')) + + it('starting again replaces the timer instead of stacking a second poller', () => { + startProcessMonitor({ ...ATTRIBUTION, pollMs: 500 }) + const clear = vi.spyOn(globalThis, 'clearInterval') + const set = vi.spyOn(globalThis, 'setInterval') + startProcessMonitor({ ...ATTRIBUTION, pollMs: 1500 }) + expect(clear).toHaveBeenCalled() + expect(set.mock.calls[set.mock.calls.length - 1][1]).toBe(1500) + }) + + it('stop clears the timer and is idempotent', () => { + startProcessMonitor({ ...ATTRIBUTION }) + const clear = vi.spyOn(globalThis, 'clearInterval') + stopProcessMonitor() + stopProcessMonitor() + expect(clear).toHaveBeenCalled() + }) +}) diff --git a/test/public-suffix.test.ts b/test/public-suffix.test.ts new file mode 100644 index 00000000..c5323340 --- /dev/null +++ b/test/public-suffix.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest' +import { getRegistrableDomain, buildSuffixSet, DEFAULT_SUFFIXES } from '../src/core/public-suffix' + +describe('getRegistrableDomain', () => { + const cases: Array<[string, string]> = [ + // Ordinary TLDs — the last-two-labels fallback is already correct here. + ['example.com', 'example.com'], + ['api.example.com', 'example.com'], + ['a.b.c.example.com', 'example.com'], + ['example.io', 'example.io'], + ['localhost', 'localhost'], + + // The reported defect: multi-label ccTLD suffixes. + ['shop.example.co.uk', 'example.co.uk'], + ['example.co.uk', 'example.co.uk'], + ['deep.sub.corp.com.tw', 'corp.com.tw'], + ['www.tokyo.co.jp', 'tokyo.co.jp'], + ['a.b.gov.au', 'b.gov.au'], + + // Platform suffixes — the half that bites hardest in bug-bounty scopes. + ['target.github.io', 'target.github.io'], + ['docs.target.github.io', 'target.github.io'], + ['mybucket.s3.amazonaws.com', 'mybucket.s3.amazonaws.com'], + ['app.azurewebsites.net', 'app.azurewebsites.net'], + ['thing.herokuapp.com', 'thing.herokuapp.com'], + + // Degenerate: the host IS the suffix. Nothing shorter to return. + ['co.uk', 'co.uk'], + ['github.io', 'github.io'], + + ['API.Example.CO.UK', 'example.co.uk'] + ] + + for (const [host, expected] of cases) { + it(`${host} → ${expected}`, () => { + expect(getRegistrableDomain(host)).toBe(expected) + }) + } + + // The safety property the curated table rests on (see public-suffix.ts): + // a suffix the table has never heard of must degrade to the OLD behaviour + // (over-match → noise), never to a longer domain (under-match → silence). + it('an unknown suffix falls back to the last two labels — noisy, never silent', () => { + expect(getRegistrableDomain('shop.example.unknown-suffix.zz')).toBe('unknown-suffix.zz') + }) + + it('the longest matching suffix wins', () => { + const set = buildSuffixSet(['example.co.uk']) + expect(getRegistrableDomain('a.b.example.co.uk', set)).toBe('b.example.co.uk') + }) +}) + +describe('buildSuffixSet', () => { + it('is additive — built-ins survive an operator list', () => { + const set = buildSuffixSet(['weird.internal']) + expect(set.has('co.uk')).toBe(true) + expect(set.has('weird.internal')).toBe(true) + }) + + it('normalises leading wildcards, dots and case', () => { + const set = buildSuffixSet(['*.Corp.Internal', '.trailing.zz.']) + expect(set.has('corp.internal')).toBe(true) + expect(set.has('trailing.zz')).toBe(true) + }) + + it('ignores single-label entries — the fallback already covers bare TLDs', () => { + const before = DEFAULT_SUFFIXES.size + expect(buildSuffixSet(['com', '', ' ']).size).toBe(before) + }) + + it('an operator entry takes effect end to end', () => { + expect(getRegistrableDomain('a.b.corp.internal')).toBe('corp.internal') + expect(getRegistrableDomain('a.b.corp.internal', buildSuffixSet(['corp.internal']))).toBe('b.corp.internal') + }) +}) diff --git a/test/renderer-smoke.test.tsx b/test/renderer-smoke.test.tsx index 3f7a252f..69df51ad 100644 --- a/test/renderer-smoke.test.tsx +++ b/test/renderer-smoke.test.tsx @@ -19,7 +19,6 @@ import ProjectPicker from '../src/renderer/src/components/ProjectPicker' import Sidebar from '../src/renderer/src/components/Sidebar' import StatusBar from '../src/renderer/src/components/StatusBar' import IPStatusCard from '../src/renderer/src/components/IPStatusCard' -import { TargetView } from '../src/renderer/src/components/TargetView' import { ScopeStatus } from '../src/renderer/src/components/ScopeStatus' import { LootPanel } from '../src/renderer/src/components/LootPanel' import { SearchPanel } from '../src/renderer/src/components/SearchPanel' @@ -214,7 +213,6 @@ describe('renderer views render without throwing', () => { ['Sidebar', () => {}} />], ['StatusBar', () => ], ['IPStatusCard', () => ], - ['TargetView', () => ], ['ScopeStatus', () => ], ['LootPanel', () => ], ['SearchPanel', () => ], diff --git a/test/retention.test.ts b/test/retention.test.ts index 0733bed4..9c4bc47c 100644 --- a/test/retention.test.ts +++ b/test/retention.test.ts @@ -55,7 +55,7 @@ describeDB('retention sweep', () => { const cast = seed('casts', 'old.cast', 400) const shot = seed('screenshots', 'old.jpg', 400) const swept = sweepRetention({}, OPTS) - expect(swept).toEqual({ cast: 0, screenshots: 0, agentTranscripts: 0 }) + expect(swept).toEqual({ cast: 0, screenshots: 0, agentTranscripts: 0, io: 0, ioCompressed: 0 }) expect(fs.existsSync(cast)).toBe(true) expect(fs.existsSync(shot)).toBe(true) }) @@ -85,11 +85,54 @@ describeDB('retention sweep', () => { const swept = sweepRetention( { terminal: { castKeepDays: 30 }, screenshots: { keepDays: 0 }, agentTranscripts: { keepDays: 10 } }, OPTS ) - expect(swept).toEqual({ cast: 1, screenshots: 0, agentTranscripts: 1 }) + expect(swept).toEqual({ cast: 1, screenshots: 0, agentTranscripts: 1, io: 0, ioCompressed: 0 }) expect(auditEvents('screenshot_pruned')).toBe(0) expect(auditEvents('agent_transcript_pruned')).toBe(1) }) + it('prunes io_ref sidecar bodies on their own window, with an audit event', () => { + const oldBody = seed('io', 'a'.repeat(64) + '.bin', 40) + const freshBody = seed('io', 'b'.repeat(64) + '.bin', 2) + const swept = sweepRetention({ io: { keepDays: 30 } }, OPTS) + expect(swept.io).toBe(1) + expect(fs.existsSync(oldBody)).toBe(false) + expect(fs.existsSync(freshBody)).toBe(true) + // pruned, not vanished: the reviewer finds a row explaining the gap + expect(auditEvents('io_pruned')).toBe(1) + }) + + it('warms (compresses) io bodies past warmDays before pruning', () => { + const sha = 'c'.repeat(64) + const d = path.join(dir, 'io') + fs.mkdirSync(d, { recursive: true }) + const f = path.join(d, `${sha}.bin`) + fs.writeFileSync(f, JSON.stringify({ blob: 'Q'.repeat(20 * 1024) })) + ageFile(f, 10) + const swept = sweepRetention({ io: { warmDays: 7 } }, OPTS) + expect(swept.ioCompressed).toBe(1) + expect(fs.existsSync(f)).toBe(false) // raw reclaimed + expect(fs.existsSync(path.join(d, `${sha}.bin.gz`))).toBe(true) + }) + + it('evicts unpinned io bodies when the store is over its size cap', () => { + const d = path.join(dir, 'io') + fs.mkdirSync(d, { recursive: true }) + const mk = (hexChar: string, days: number): string => { + const name = path.join(d, `${hexChar.repeat(64)}.bin`) // valid 64-hex sha stem + fs.writeFileSync(name, 'Z'.repeat(2000)) + ageFile(name, days) + return name + } + const a = mk('a', 30) // oldest — evicted first under pressure + const b = mk('b', 5) + // 4000 bytes total, cap 2500 → must drop ~1 body; unpinned (orphans) go by age + const swept = sweepRetention({ io: { maxBytes: 2500 } }, OPTS) + expect(swept.io).toBeGreaterThanOrEqual(1) + expect(fs.existsSync(a)).toBe(false) // oldest unpinned evicted + expect(fs.existsSync(b)).toBe(true) + expect(auditEvents('io_pruned')).toBeGreaterThanOrEqual(1) + }) + it('ignores files it does not own', () => { const other = seed('casts', 'notes.txt', 400) sweepRetention({ terminal: { castKeepDays: 1 } }, OPTS) @@ -99,7 +142,7 @@ describeDB('retention sweep', () => { it('is a no-op without an operator id — every event needs attribution', () => { const f = seed('casts', 'a.cast', 400) expect(sweepRetention({ terminal: { castKeepDays: 1 } }, { engagementId: 'e', operatorId: '' })) - .toEqual({ cast: 0, screenshots: 0, agentTranscripts: 0 }) + .toEqual({ cast: 0, screenshots: 0, agentTranscripts: 0, io: 0, ioCompressed: 0 }) expect(fs.existsSync(f)).toBe(true) }) diff --git a/test/scan-parsers.test.ts b/test/scan-parsers.test.ts new file mode 100644 index 00000000..8e938eda --- /dev/null +++ b/test/scan-parsers.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest' +import { createRequire } from 'module' + +// scan-parsers bundled pack (SPEC-AI-ERA-PLUGINS Gap 1). The parser ships as a +// dependency-free CommonJS module inside the plugin (runs in the operator's +// shell), so we require it directly and pin its output shape here. +const require_ = createRequire(import.meta.url) +const { parseNmapGreppable, parseNucleiJsonl, parse } = require_('../plugins/scan-parsers/parse.js') + +describe('parseNmapGreppable', () => { + const sample = [ + '# Nmap 7.94 scan', + 'Host: 10.0.0.5 (web.example.com)\tPorts: 22/open/tcp//ssh//OpenSSH 8.9//, 80/open/tcp//http//nginx 1.24//, 443/closed/tcp//https///\tStatus: Up', + 'Host: 10.0.0.6 ()\tPorts: 3306/open/tcp//mysql//MariaDB//\tStatus: Up', + 'garbage line' + ].join('\n') + + it('extracts hosts and their OPEN ports only', () => { + const hosts = parseNmapGreppable(sample) + expect(hosts).toHaveLength(2) + expect(hosts[0].host).toBe('10.0.0.5') + expect(hosts[0].hostname).toBe('web.example.com') + // 443 is closed → dropped; 22 + 80 kept + expect(hosts[0].ports.map((p: { port: number }) => p.port)).toEqual([22, 80]) + expect(hosts[0].ports[0]).toMatchObject({ port: 22, proto: 'tcp', service: 'ssh', version: 'OpenSSH 8.9' }) + }) + + it('tolerates blank hostname and malformed lines', () => { + const hosts = parseNmapGreppable(sample) + expect(hosts[1].hostname).toBe('') + expect(hosts[1].ports[0].service).toBe('mysql') + }) + + it('returns [] for empty / non-string input', () => { + expect(parseNmapGreppable('')).toEqual([]) + expect(parseNmapGreppable(undefined as unknown as string)).toEqual([]) + }) +}) + +describe('parseNucleiJsonl', () => { + const sample = [ + '{"template-id":"CVE-2021-1234","info":{"name":"Example RCE","severity":"critical"},"host":"https://x.example.com","matched-at":"https://x.example.com/api","type":"http"}', + 'not json', + '', + '{"template-id":"tech-detect","info":{"name":"nginx","severity":"info"},"host":"https://x.example.com","type":"http"}' + ].join('\n') + + it('parses one finding per valid JSON line, skipping junk', () => { + const f = parseNucleiJsonl(sample) + expect(f).toHaveLength(2) + expect(f[0]).toMatchObject({ templateId: 'CVE-2021-1234', name: 'Example RCE', severity: 'critical', host: 'https://x.example.com' }) + expect(f[1].severity).toBe('info') + }) + + it('defaults severity to unknown when absent', () => { + const f = parseNucleiJsonl('{"template-id":"t","host":"h"}') + expect(f[0].severity).toBe('unknown') + }) +}) + +describe('parse → RedLog event payloads', () => { + it('nmap → one scanner scan_result per host, target_id = host', () => { + const events = parse('nmap', 'Host: 1.2.3.4 (h)\tPorts: 80/open/tcp//http///\tStatus: Up') + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ agent_type: 'scanner', target_id: '1.2.3.4' }) + expect(events[0].data).toMatchObject({ subtype: 'scan_result', tool: 'nmap', open_port_count: 1, detectedTarget: '1.2.3.4' }) + }) + + it('nuclei → one scanner scan_result per finding, severity recorded verbatim', () => { + const events = parse('nuclei', '{"template-id":"t","info":{"name":"n","severity":"high"},"host":"h"}') + expect(events[0]).toMatchObject({ agent_type: 'scanner', target_id: 'h' }) + expect(events[0].data).toMatchObject({ subtype: 'scan_result', tool: 'nuclei', template_id: 't', severity: 'high' }) + }) + + it('unknown tool → []', () => { + expect(parse('whatever', 'x')).toEqual([]) + }) +}) diff --git a/test/scope-adherence.test.ts b/test/scope-adherence.test.ts new file mode 100644 index 00000000..2a4bb439 --- /dev/null +++ b/test/scope-adherence.test.ts @@ -0,0 +1,218 @@ +// G-D1: the positive proof. RedLog does not prevent (ALERT-ROLES Part D), so +// "provably did not exceed scope" IS the deliverable — and until this existed +// only the accusation half did. A client reading "3 violations" cannot tell 3 +// out of 250 targets from 3 out of 5; the denominator is the whole point. + +import { describe, it, expect } from 'vitest' +import { buildAdherenceReport, summariseAdherence } from '../src/core/scope-adherence' + +const SCOPE = { + targets: ['192.168.1.10', '192.168.1.20', '*.app.example.com'], + excludeTargets: ['dc01.app.example.com'] +} + +let clock = 1_700_000_000_000 +const shell = (target: string, command = `nmap ${target}`): Record => ({ + timestamp: (clock += 1000), + agentType: 'shell', + targetId: target, + data: { subtype: 'command_start', command, detectedTarget: target } +}) +const violation = ( + target: string, + reason: string, + authority: 'fact' | 'inferred' +): Record => ({ + timestamp: (clock += 1000), + agentType: 'system', + targetId: target, + data: { subtype: 'scope_violation', target, reason, authority } +}) + +const build = (events: Array>, scope = SCOPE): ReturnType => + buildAdherenceReport(events as never, scope, { generatedAt: 1_700_000_999_999, engagementId: 'eng-1' }) + +describe('buildAdherenceReport — the denominator', () => { + it('counts distinct targets and total actions separately', () => { + const r = build([shell('192.168.1.10'), shell('192.168.1.10', 'curl 192.168.1.10'), shell('192.168.1.20')]) + expect(r.totals.targets).toBe(2) + expect(r.totals.actions).toBe(3) + }) + + it('classifies every target touched, not just the ones that alerted', () => { + const r = build([ + shell('192.168.1.10'), // D0 + shell('api.app.example.com'), // D0 + shell('dc01.app.example.com'), // D1 + shell('192.168.1.55'), // D2 subnet + shell('vpn.example.com'), // D2 domain + shell('8.8.8.8') // D3 + ]) + expect(r.totals).toMatchObject({ + targets: 6, in_scope: 2, excluded: 1, adjacent_subnet: 1, adjacent_domain: 1, unrelated: 1 + }) + }) + + it('the summary line is the sentence a client deliverable needs', () => { + const r = build([shell('192.168.1.10'), shell('192.168.1.20'), shell('192.168.1.55')]) + expect(summariseAdherence(r)).toBe('3 targets, 2 in scope, 0 excluded, 1 adjacent') + }) + + it('mentions off-list targets only when there are some', () => { + expect(summariseAdherence(build([shell('192.168.1.10')]))).not.toContain('off-list') + expect(summariseAdherence(build([shell('8.8.8.8')]))).toContain('1 off-list') + }) + + it('an engagement with nothing out of bounds says so with a denominator', () => { + const r = build([shell('192.168.1.10'), shell('192.168.1.20'), shell('api.app.example.com')]) + expect(r.totals.in_scope).toBe(3) + expect(r.totals.excluded + r.totals.adjacent_subnet + r.totals.adjacent_domain + r.totals.unrelated).toBe(0) + }) +}) + +describe('buildAdherenceReport — what counts as a target touched', () => { + // A scope_violation carries the offending host in targetId. Counting it would + // inflate the very target it is reporting on. + it('does not count RedLog\'s own bookkeeping as an operator action', () => { + const r = build([shell('192.168.1.55'), violation('192.168.1.55', 'adjacent_subnet', 'inferred')]) + expect(r.totals.targets).toBe(1) + expect(r.totals.actions).toBe(1) + }) + + it('falls back to targetId when no target was extracted from a command', () => { + const r = build([{ timestamp: 1, agentType: 'http', targetId: '192.168.1.10', data: {} }]) + expect(r.totals.targets).toBe(1) + expect(r.targets[0].target).toBe('192.168.1.10') + }) + + it('ignores events with no target at all', () => { + const r = build([{ timestamp: 1, agentType: 'shell', data: { subtype: 'command_start', command: 'ls' } }]) + expect(r.totals.targets).toBe(0) + expect(r.totals.actions).toBe(0) + }) +}) + +describe('buildAdherenceReport — the rows', () => { + it('tracks first/last seen and a capped command sample', () => { + const events = Array.from({ length: 8 }, (_, i) => shell('192.168.1.10', `cmd-${i}`)) + const r = build(events) + const row = r.targets[0] + expect(row.count).toBe(8) + expect(row.firstSeen).toBeLessThan(row.lastSeen) + expect(row.commands).toHaveLength(5) // the full record is the timeline + }) + + // A client reads the problems first, not an alphabetical list. + it('orders worst-first, then by traffic', () => { + const r = build([ + shell('192.168.1.10'), shell('192.168.1.10'), + shell('8.8.8.8'), + shell('192.168.1.55'), + shell('dc01.app.example.com') + ]) + expect(r.targets.map((t) => t.distance)).toEqual([ + 'excluded', 'adjacent_subnet', 'unrelated', 'in_scope' + ]) + }) +}) + +describe('buildAdherenceReport — honesty about re-classification', () => { + const configChanged = (changed: Record): Record => ({ + timestamp: (clock += 1000), + agentType: 'system', + data: { subtype: 'config_changed', changed } + }) + + it('carries the violations exactly as they were recorded at the time', () => { + const r = build([shell('192.168.1.55'), violation('192.168.1.55', 'adjacent_subnet', 'inferred')]) + expect(r.recordedViolations).toEqual([ + { target: '192.168.1.55', reason: 'adjacent_subnet', authority: 'inferred', timestamp: expect.any(Number) } + ]) + }) + + // Re-classification uses the CURRENT scope. Saying so is the difference + // between a caveat and a silent error. + it('surfaces scope edits inside the window', () => { + const r = build([ + shell('192.168.1.10'), + configChanged({ 'scope.targets': { from: ['a'], to: ['b'] }, 'network.whitelist': { from: [], to: ['x'] } }) + ]) + expect(r.scopeChanges).toHaveLength(1) + expect(Object.keys(r.scopeChanges[0].changed)).toEqual(['scope.targets']) + }) + + it('ignores config changes that did not touch the scope', () => { + const r = build([shell('192.168.1.10'), configChanged({ 'network.whitelist': { from: [], to: ['x'] } })]) + expect(r.scopeChanges).toHaveLength(0) + }) + + it('no disagreement when the recorded reason still matches', () => { + const r = build([ + shell('dc01.app.example.com'), + violation('dc01.app.example.com', 'excluded_target', 'fact'), + shell('192.168.1.55'), + violation('192.168.1.55', 'adjacent_subnet', 'inferred') + ]) + expect(r.disagreements).toEqual([]) + }) + + // The honest consequence of editing scope mid-engagement, not a bug. + it('flags a target whose live classification no longer matches what was recorded', () => { + const r = build( + [shell('192.168.1.55'), violation('192.168.1.55', 'adjacent_subnet', 'inferred')], + { targets: ['192.168.1.0/24'], excludeTargets: [] } // widened since + ) + expect(r.disagreements).toEqual([ + { target: '192.168.1.55', recorded: 'adjacent_subnet', current: 'in_scope' } + ]) + }) + + it('reports each disagreeing target once, however many times it fired', () => { + const r = build( + [ + shell('192.168.1.55'), + violation('192.168.1.55', 'adjacent_subnet', 'inferred'), + violation('192.168.1.55', 'adjacent_subnet', 'inferred') + ], + { targets: ['192.168.1.0/24'], excludeTargets: [] } + ) + expect(r.disagreements).toHaveLength(1) + }) + + it('records the scope it judged against, so the report is self-describing', () => { + const r = build([shell('192.168.1.10')]) + expect(r.scope.targets).toEqual(SCOPE.targets) + expect(r.engagementId).toBe('eng-1') + expect(r.generatedAt).toBe(1_700_000_999_999) + }) +}) + +// G-D2: the report says what scope it judged against — and now, where that +// scope came from. Without the digest a reviewer can only take the path on +// trust, which is not the kind of claim an evidence tool should be making. +describe('buildAdherenceReport — scope provenance travels with the report', () => { + const PROV = { + path: '/engagements/acme/scope.txt', + digest: 'a'.repeat(64), + bytes: 128, + entries: 3, + modifiedAt: 1_699_000_000_000, + loadedAt: 1_700_000_000_000 + } + + it('carries the provenance verbatim', () => { + const r = buildAdherenceReport( + [shell('192.168.1.10')] as never, + { ...SCOPE, provenance: PROV }, + { generatedAt: 1, engagementId: 'eng-1' } + ) + expect(r.scope.provenance).toEqual(PROV) + }) + + // A typed-in scope is a legitimate state, not an error — it just cannot be + // joined to a document, and the report should not pretend otherwise. + it('a typed-in scope reports no provenance rather than a fake one', () => { + const r = build([shell('192.168.1.10')]) + expect(r.scope.provenance ?? null).toBeNull() + }) +}) diff --git a/test/scope-monitor-behaviour.test.ts b/test/scope-monitor-behaviour.test.ts new file mode 100644 index 00000000..d6697cb9 --- /dev/null +++ b/test/scope-monitor-behaviour.test.ts @@ -0,0 +1,471 @@ +// Behavioural coverage for the REAL ScopeMonitor + classifyTarget. +// +// `scope-monitor.test.ts` re-implements the matching logic inline, so it proves +// the algorithm is sound but never touches the shipped class — which means the +// one option that decides whether an operator is warned at all, +// `scope.alertFloor`, had no coverage. This file drives the class itself. +// +// No operatorId is configured anywhere below: `recordViolation` short-circuits +// before `insertEvent` without one, so the violation bookkeeping is exercised +// without needing a DB. + +import { describe, it, expect, beforeEach } from 'vitest' +import { ScopeMonitor, classifyTarget, classifyDistance } from '../src/core/scope-monitor' + +// `*.app.example.com`, not `*.example.com`: a wildcard covers every host under +// it, so a scope of `*.example.com` would leave no same-root-but-out-of-scope +// host to test the "warn only on neighbours" rule with. `vpn.example.com` is +// that host here — same root domain (example.com), outside the scope. +const SCOPE = ['10.0.0.0/24', '*.app.example.com'] +const EXCLUDED = ['10.0.0.1', 'dc01.app.example.com'] + +describe('classifyTarget — the four verdicts', () => { + const cfg = { targets: SCOPE, excludeTargets: EXCLUDED } + + const cases: Array<[string, string | null | undefined, typeof cfg, string]> = [ + ['no target at all → unknown (caller picks the safe default)', null, cfg, 'unknown'], + ['empty string → unknown', '', cfg, 'unknown'], + ['undefined → unknown', undefined, cfg, 'unknown'], + ['no scope configured → everything is in scope', 'anything.test', { targets: [], excludeTargets: [] }, 'in_scope'], + ['IP inside the CIDR → in_scope', '10.0.0.9', cfg, 'in_scope'], + ['IP outside the CIDR → out_of_scope', '10.0.1.9', cfg, 'out_of_scope'], + ['wildcard subdomain hit → in_scope', 'api.app.example.com', cfg, 'in_scope'], + ['wildcard also covers the bare domain it is anchored on', 'app.example.com', cfg, 'in_scope'], + ['same root but outside the wildcard → out_of_scope', 'vpn.example.com', cfg, 'out_of_scope'], + ['unrelated domain → out_of_scope', 'google.com', cfg, 'out_of_scope'], + ['excluded IP beats the enclosing CIDR', '10.0.0.1', cfg, 'excluded'], + ['excluded host beats the wildcard', 'dc01.app.example.com', cfg, 'excluded'] + ] + + for (const [name, target, config, expected] of cases) { + it(name, () => { + expect(classifyTarget(target, config)).toBe(expected) + }) + } + + it('an exclude entry alone does not put an unrelated host in scope', () => { + expect(classifyTarget('other.test', { targets: SCOPE, excludeTargets: ['other.test'] })).toBe('excluded') + }) +}) + +describe('ScopeMonitor.checkTarget — alertFloor: adjacent (default)', () => { + let m: ScopeMonitor + + beforeEach(() => { + m = new ScopeMonitor() + m.configure({ targets: SCOPE, excludeTargets: EXCLUDED, alertFloor: 'adjacent' }) + }) + + it('in-scope target: in scope, no violation, nothing recorded', () => { + expect(m.checkTarget('10.0.0.9', 'nmap 10.0.0.9')).toEqual({ inScope: true, violation: false }) + expect(m.getViolationCount()).toBe(0) + }) + + it('same-root out-of-scope host raises a violation', () => { + expect(m.checkTarget('vpn.example.com', 'curl vpn.example.com')).toEqual({ inScope: false, violation: true }) + expect(m.getViolationCount()).toBe(1) + }) + + // Pre-G-B3 this raised: the IP branch skipped the proximity filter entirely, + // so every out-of-scope IP alerted as loudly as hitting the wrong box on the + // target segment. SCOPE's only IP entry is a CIDR (10.0.0.0/24), which states + // its own boundary and is never widened — so 192.168.50.1 is D3, not D2. + it('an out-of-scope IP outside every container is D3: out of scope, no violation', () => { + expect(m.checkTarget('192.168.50.1', 'nmap 192.168.50.1')).toEqual({ inScope: false, violation: false }) + expect(m.getViolationCount()).toBe(0) + expect(m.getUnrelatedCount()).toBe(1) + }) + + it('an unrelated domain is out of scope but NOT a violation — no alert fatigue', () => { + expect(m.checkTarget('google.com', 'curl google.com')).toEqual({ inScope: false, violation: false }) + expect(m.getViolationCount()).toBe(0) + }) + + it('an excluded target raises a violation even though it sits inside the scope', () => { + expect(m.checkTarget('dc01.app.example.com', 'nmap dc01.app.example.com').violation).toBe(true) + }) + + it('records target, command and a timestamp for each violation', () => { + const before = Date.now() + m.checkTarget('vpn.example.com', 'curl -s https://vpn.example.com/login') + const [v] = m.getViolations() + expect(v.target).toBe('vpn.example.com') + expect(v.command).toBe('curl -s https://vpn.example.com/login') + expect(v.timestamp).toBeGreaterThanOrEqual(before) + }) + + it('accumulates repeat violations rather than deduping them', () => { + m.checkTarget('vpn.example.com', 'curl vpn.example.com') + m.checkTarget('vpn.example.com', 'curl vpn.example.com') + expect(m.getViolationCount()).toBe(2) + }) + + it('getViolations hands back a copy — callers cannot mutate the record', () => { + m.checkTarget('vpn.example.com', 'curl vpn.example.com') + m.getViolations().push({ target: 'forged', command: 'x', timestamp: 0 }) + expect(m.getViolationCount()).toBe(1) + }) +}) + +describe('ScopeMonitor.checkTarget — alertFloor: excluded_only', () => { + let m: ScopeMonitor + + beforeEach(() => { + m = new ScopeMonitor() + m.configure({ targets: SCOPE, excludeTargets: EXCLUDED, alertFloor: 'excluded_only' }) + }) + + it('a same-root out-of-scope host is silent: still out of scope, no violation', () => { + expect(m.checkTarget('vpn.example.com', 'curl vpn.example.com')).toEqual({ inScope: false, violation: false }) + expect(m.getViolationCount()).toBe(0) + }) + + it('an out-of-scope IP is silent too', () => { + expect(m.checkTarget('192.168.50.1', 'nmap 192.168.50.1').violation).toBe(false) + }) + + it('silences an adjacent subnet as well — D2 is inferred, so it is silenceable', () => { + const q = new ScopeMonitor() + q.configure({ targets: ['192.168.1.10'], alertFloor: 'excluded_only' }) + expect(q.checkTarget('192.168.1.55', 'nmap 192.168.1.55').violation).toBe(false) + }) + + it('an EXCLUDED target still warns — "keep off X" is not a preference call', () => { + expect(m.checkTarget('dc01.app.example.com', 'nmap dc01.app.example.com').violation).toBe(true) + expect(m.getViolationCount()).toBe(1) + }) + + it('in-scope traffic is unaffected', () => { + expect(m.checkTarget('10.0.0.9', 'nmap 10.0.0.9').inScope).toBe(true) + }) +}) + +describe('ScopeMonitor.configure — which fields a partial update touches', () => { + it('an empty scope list means "no scope set": everything passes, nothing is recorded', () => { + const m = new ScopeMonitor() + m.configure({ targets: [], excludeTargets: [] }) + expect(m.checkTarget('anything.test', 'curl anything.test')).toEqual({ inScope: true, violation: false }) + expect(m.isConfigured()).toBe(false) + }) + + it('isConfigured flips once targets are set', () => { + const m = new ScopeMonitor() + expect(m.isConfigured()).toBe(false) + m.configure({ targets: SCOPE }) + expect(m.isConfigured()).toBe(true) + }) + + it('the floor defaults to adjacent when the caller omits it', () => { + const m = new ScopeMonitor() + m.configure({ targets: SCOPE }) + expect(m.checkTarget('vpn.example.com', 'curl vpn.example.com').violation).toBe(true) + }) + + it('omitting alertFloor on a later call keeps the value already set', () => { + const m = new ScopeMonitor() + m.configure({ targets: SCOPE, alertFloor: 'excluded_only' }) + m.configure({ targets: SCOPE, excludeTargets: [] }) // no alertFloor key + expect(m.checkTarget('vpn.example.com', 'curl vpn.example.com').violation).toBe(false) + }) + + it('re-configuring the target list re-derives the root domains used for warnings', () => { + const m = new ScopeMonitor() + m.configure({ targets: ['*.app.example.com'] }) + expect(m.checkTarget('vpn.example.com', 'curl vpn.example.com').violation).toBe(true) + m.configure({ targets: ['*.app.other.com'] }) + // example.com is no longer a known root, so the same host goes quiet. + expect(m.checkTarget('vpn.example.com', 'curl vpn.example.com').violation).toBe(false) + expect(m.checkTarget('vpn.other.com', 'curl vpn.other.com').violation).toBe(true) + }) + + it('keeps the full command in memory — only the chained event slices it to 200 chars', () => { + const m = new ScopeMonitor() + m.configure({ targets: SCOPE }) + const long = 'curl ' + 'a'.repeat(400) + m.checkTarget('vpn.example.com', long) + expect(m.getViolations()[0].command).toBe(long) + }) +}) + +// The distance ladder (`docs/ALERT-ROLES.md` Part B). D1 > D0 > D2 > D3. +describe('classifyDistance — the ladder', () => { + // A single-IP entry carries no boundary, so it expands to its /24 container. + // A CIDR entry states one, so it does not expand. + const cfg = { + targets: ['192.168.1.10', '10.0.0.0/24', 'www.example.com', '*.app.example.com'], + excludeTargets: ['192.168.1.99', 'dc01.app.example.com'] + } + + const cases: Array<[string, string, string]> = [ + ['D0 single-IP entry, exact hit ', '192.168.1.10', 'in_scope'], + ['D0 inside the stated CIDR ', '10.0.0.9', 'in_scope'], + ['D0 exact host entry ', 'www.example.com', 'in_scope'], + ['D0 under the wildcard ', 'api.app.example.com', 'in_scope'], + ['D1 excluded IP beats its container ', '192.168.1.99', 'excluded'], + ['D1 excluded host beats the wildcard ', 'dc01.app.example.com', 'excluded'], + ['D2 same subnet as a single-IP entry ', '192.168.1.55', 'adjacent_subnet'], + ['D2 same registrable domain ', 'admin.example.com', 'adjacent_domain'], + ['D3 outside a stated CIDR is NOT near ', '10.0.1.9', 'unrelated'], + ['D3 unrelated subnet ', '172.16.4.4', 'unrelated'], + ['D3 unrelated domain ', 'google.com', 'unrelated'], + ['D3 public resolver during recon ', '8.8.8.8', 'unrelated'] + ] + + for (const [name, target, expected] of cases) { + it(`${name} → ${expected}`, () => { + expect(classifyDistance(target, cfg)).toBe(expected) + }) + } + + it('no scope configured means everything is in scope', () => { + expect(classifyDistance('anything.test', { targets: [], excludeTargets: [] })).toBe('in_scope') + }) + + // G-B1: the requested "same subnet, wrong host" alert. + it('G-B1: enumerated hosts make their segment adjacent — the wrong-box case', () => { + const enumerated = { targets: ['192.168.1.10', '192.168.1.20'], excludeTargets: [] } + expect(classifyDistance('192.168.1.55', enumerated)).toBe('adjacent_subnet') + expect(classifyDistance('192.168.2.55', enumerated)).toBe('unrelated') + }) + + // G-B3: the noise that got the channel muted. + it('G-B3: a stated CIDR is not widened — its complement is unrelated, not adjacent', () => { + const stated = { targets: ['192.168.1.0/24'], excludeTargets: [] } + expect(classifyDistance('192.168.2.55', stated)).toBe('unrelated') + expect(classifyDistance('8.8.8.8', stated)).toBe('unrelated') + }) + + it('proximityBits widens or narrows the container for single-IP entries only', () => { + const t = { targets: ['192.168.1.10', '10.0.0.0/24'], excludeTargets: [] } + expect(classifyDistance('192.168.2.55', { ...t, proximityBits: 16 })).toBe('adjacent_subnet') + expect(classifyDistance('192.168.2.55', { ...t, proximityBits: 24 })).toBe('unrelated') + // The CIDR entry is untouched by the knob at any width. + expect(classifyDistance('10.0.1.9', { ...t, proximityBits: 16 })).toBe('unrelated') + }) + + it('a nonsensical proximityBits falls back to the default rather than opening up', () => { + const t = { targets: ['192.168.1.10'], excludeTargets: [] } + for (const bits of [0, -1, 33, 1.5, NaN]) { + expect(classifyDistance('192.168.2.55', { ...t, proximityBits: bits })).toBe('unrelated') + expect(classifyDistance('192.168.1.55', { ...t, proximityBits: bits })).toBe('adjacent_subnet') + } + }) + + // The deliberate asymmetry: a wildcard still expands to its registrable + // domain, because that domain is the ownership boundary the authorisation is + // about. Scoped to staging, hitting prod is exactly what D2 exists to catch. + it('a wildcard entry still makes its registrable domain adjacent', () => { + const staging = { targets: ['*.staging.example.com'], excludeTargets: [] } + expect(classifyDistance('prod.example.com', staging)).toBe('adjacent_domain') + expect(classifyDistance('google.com', staging)).toBe('unrelated') + }) +}) + +describe('ScopeMonitor — D2 subnet adjacency end-to-end', () => { + let m: ScopeMonitor + + beforeEach(() => { + m = new ScopeMonitor() + m.configure({ targets: ['192.168.1.10', '192.168.1.20'], excludeTargets: [], alertFloor: 'adjacent' }) + }) + + it('warns on the wrong box in the right segment', () => { + expect(m.checkTarget('192.168.1.55', 'nmap 192.168.1.55')).toEqual({ inScope: false, violation: true }) + expect(m.getViolationCount()).toBe(1) + }) + + it('stays quiet on an unrelated host and counts it instead of dropping it', () => { + expect(m.checkTarget('8.8.8.8', 'dig @8.8.8.8 example.com').violation).toBe(false) + expect(m.getViolationCount()).toBe(0) + expect(m.getUnrelatedCount()).toBe(1) + }) + + it('re-configuring proximityBits takes effect on the next check', () => { + expect(m.checkTarget('192.168.9.55', 'nmap 192.168.9.55').violation).toBe(false) + m.configure({ proximityBits: 16 }) + expect(m.checkTarget('192.168.9.55', 'nmap 192.168.9.55').violation).toBe(true) + }) +}) + +// G-B2: the registrable-domain rule that decides D2 `adjacent_domain`. +describe('classifyDistance — registrable domain, not last-two-labels', () => { + it('a multi-label ccTLD no longer drags in the whole suffix', () => { + // Narrow wildcard for the same reason SCOPE uses `*.app.example.com`: a + // wildcard covers everything under it, leaving no same-domain host outside. + const cfg = { targets: ['*.app.example.co.uk'], excludeTargets: [] } + // Pre-G-B2 the scope root was `co.uk`, so BOTH of these were adjacent. + expect(classifyDistance('vpn.example.co.uk', cfg)).toBe('adjacent_domain') + expect(classifyDistance('www.bbc.co.uk', cfg)).toBe('unrelated') + expect(classifyDistance('shop.other.com.tw', { targets: ['*.corp.com.tw'], excludeTargets: [] })) + .toBe('unrelated') + }) + + it('a platform suffix no longer makes every tenant a neighbour', () => { + const cfg = { targets: ['target.github.io'], excludeTargets: [] } + expect(classifyDistance('someone-else.github.io', cfg)).toBe('unrelated') + expect(classifyDistance('docs.target.github.io', cfg)).toBe('adjacent_domain') + }) + + it('bucket neighbours on s3 are not adjacent to each other', () => { + const cfg = { targets: ['client-assets.s3.amazonaws.com'], excludeTargets: [] } + expect(classifyDistance('someone-elses-bucket.s3.amazonaws.com', cfg)).toBe('unrelated') + }) + + it('ordinary domains are unchanged — the common case did not regress', () => { + const cfg = { targets: ['*.app.example.com'], excludeTargets: [] } + expect(classifyDistance('vpn.example.com', cfg)).toBe('adjacent_domain') + expect(classifyDistance('google.com', cfg)).toBe('unrelated') + }) + + it('publicSuffixes lets an operator teach it a suffix per engagement', () => { + const cfg = { targets: ['*.a.corp.internal'], excludeTargets: [] } + expect(classifyDistance('b.corp.internal', cfg)).toBe('adjacent_domain') + expect(classifyDistance('b.corp.internal', { ...cfg, publicSuffixes: ['corp.internal'] })) + .toBe('unrelated') + }) +}) + +describe('ScopeMonitor — publicSuffixes wiring', () => { + it('configure rebuilds the suffix set and the next check uses it', () => { + const m = new ScopeMonitor() + m.configure({ targets: ['*.a.corp.internal'], alertFloor: 'adjacent' }) + expect(m.checkTarget('b.corp.internal', 'curl b.corp.internal').violation).toBe(true) + m.configure({ publicSuffixes: ['corp.internal'] }) + expect(m.checkTarget('b.corp.internal', 'curl b.corp.internal').violation).toBe(false) + }) +}) + +// G-A5's twin on the scope side: `IP_RE` matched only dotted quads, so a v6 +// target was routed through the DOMAIN matcher. A v6 CIDR scope entry never +// matched, and on the adjacency path a v6 host fell out as `unrelated` — +// silent, which is the one direction this subsystem must not fail in. +describe('classifyDistance over IPv6', () => { + const cfg = { + targets: ['2001:db8:0:1::/64', '2001:db8:0:9::10'], + excludeTargets: ['2001:db8:0:1::99'] + } + + const cases: Array<[string, string, string]> = [ + ['D0 inside the stated v6 CIDR ', '2001:db8:0:1::5', 'in_scope'], + ['D0 the exact single-address entry ', '2001:db8:0:9::10', 'in_scope'], + ['D0 an equivalent notation of it ', '2001:0db8:0000:0009:0000:0000:0000:0010', 'in_scope'], + ['D1 excluded beats its CIDR ', '2001:db8:0:1::99', 'excluded'], + ['D2 same /64 as a single-IP entry ', '2001:db8:0:9::ff', 'adjacent_subnet'], + ['D3 outside a stated CIDR ', '2001:db8:0:2::5', 'unrelated'], + ['D3 a different /32 entirely ', '2001:dead::1', 'unrelated'] + ] + + for (const [name, target, expected] of cases) { + it(`${name} → ${expected}`, () => { + expect(classifyDistance(target, cfg)).toBe(expected) + }) + } + + it('a v6 target is no longer mistaken for a hostname', () => { + // Pre-fix this reached the domain matcher, so nothing but a byte-identical + // string could match and the adjacency path went silent. + expect(classifyDistance('2001:db8:0:1::5', cfg)).not.toBe('unrelated') + }) + + // v6 subnets are /64 by convention, so a single-IP v6 entry expands to its + // /64 rather than to `proximityBits` (which is a v4 answer). + it('a single-IP v6 entry expands to its /64, not to proximityBits', () => { + const single = { targets: ['2001:db8:0:9::10'], excludeTargets: [] } + expect(classifyDistance('2001:db8:0:9::ff', single)).toBe('adjacent_subnet') + expect(classifyDistance('2001:db8:0:a::ff', single)).toBe('unrelated') + // The v4 knob does not reach across families. + expect(classifyDistance('2001:db8:0:a::ff', { ...single, proximityBits: 8 })).toBe('unrelated') + }) + + it('families do not cross', () => { + expect(classifyDistance('10.8.0.5', cfg)).toBe('unrelated') + expect(classifyDistance('2001:db8:0:1::5', { targets: ['10.0.0.0/8'], excludeTargets: [] })).toBe('unrelated') + }) +}) + +describe('classifyTarget over IPv6 — sanitize and alerting must agree', () => { + const cfg = { targets: ['2001:db8:0:1::/64'], excludeTargets: ['2001:db8:0:1::99'] } + + it('an in-scope v6 host is in_scope for the sanitize path too', () => { + expect(classifyTarget('2001:db8:0:1::5', cfg)).toBe('in_scope') + }) + + it('an excluded v6 host is excluded', () => { + expect(classifyTarget('2001:db8:0:1::99', cfg)).toBe('excluded') + }) + + it('an out-of-scope v6 host is out_of_scope', () => { + expect(classifyTarget('2001:db8:0:2::5', cfg)).toBe('out_of_scope') + }) +}) + +// G-C3. The ladder is ORDERED, so the control is a floor rather than N +// booleans — those would let an operator construct incoherent states ("warn on +// unrelated but not on adjacent"). D1 is present at every position by +// construction, which makes the fact-tier rule structural instead of something +// each caller has to remember. +describe('alertFloor — the three positions', () => { + const CFG = { targets: ['192.168.1.10', '*.app.example.com'], excludeTargets: ['dc01.app.example.com'] } + const at = (alertFloor: 'excluded_only' | 'adjacent' | 'all'): ScopeMonitor => { + const m = new ScopeMonitor() + m.configure({ ...CFG, alertFloor }) + return m + } + + const D1 = ['dc01.app.example.com', 'nmap dc01.app.example.com'] as const + const D2 = ['192.168.1.55', 'nmap 192.168.1.55'] as const + const D3 = ['8.8.8.8', 'dig @8.8.8.8 example.com'] as const + + it('excluded_only: D1 alone', () => { + const m = at('excluded_only') + expect(m.checkTarget(...D1).violation).toBe(true) + expect(m.checkTarget(...D2).violation).toBe(false) + expect(m.checkTarget(...D3).violation).toBe(false) + }) + + it('adjacent (default): D1 + D2', () => { + const m = at('adjacent') + expect(m.checkTarget(...D1).violation).toBe(true) + expect(m.checkTarget(...D2).violation).toBe(true) + expect(m.checkTarget(...D3).violation).toBe(false) + }) + + it('all: D1 + D2 + D3 — the strict-authorisation position', () => { + const m = at('all') + expect(m.checkTarget(...D1).violation).toBe(true) + expect(m.checkTarget(...D2).violation).toBe(true) + expect(m.checkTarget(...D3).violation).toBe(true) + }) + + // The whole reason it is a floor and not three checkboxes. + it('there is no position that silences D1', () => { + for (const floor of ['excluded_only', 'adjacent', 'all'] as const) { + expect(at(floor).checkTarget(...D1).violation).toBe(true) + } + }) + + it('a D3 emission is fact tier — a non-match has no proximity heuristic in it', () => { + const m = at('all') + m.checkTarget(...D3) + const [v] = m.getViolations() + expect(v.reason).toBe('unrelated') + expect(v.authority).toBe('fact') + }) + + // The count is kept at every floor: "silent" must stay distinguishable from + // "not looking", and the adherence report (G-D1) needs the denominator. + it('D3 is counted at every floor, emitted at only one', () => { + for (const floor of ['excluded_only', 'adjacent', 'all'] as const) { + const m = at(floor) + m.checkTarget(...D3) + expect(m.getUnrelatedCount()).toBe(1) + expect(m.getViolationCount()).toBe(floor === 'all' ? 1 : 0) + } + }) + + it('in-scope traffic is untouched at every floor', () => { + for (const floor of ['excluded_only', 'adjacent', 'all'] as const) { + expect(at(floor).checkTarget('192.168.1.10', 'nmap 192.168.1.10')).toEqual({ inScope: true, violation: false }) + } + }) +}) diff --git a/test/scope-monitor.test.ts b/test/scope-monitor.test.ts index 24b14060..27440aa1 100644 --- a/test/scope-monitor.test.ts +++ b/test/scope-monitor.test.ts @@ -1,17 +1,14 @@ import { describe, it, expect } from 'vitest' +import { classifyDistance } from '../src/core/scope-monitor' +import { getRegistrableDomain } from '../src/core/public-suffix' +import { ipInCIDR } from '../src/core/ip-match' -// Test the pure matching logic extracted from ScopeMonitor without DB dependency - -function ipToLong(ip: string): number { - return ip.split('.').reduce((acc, o) => (acc << 8) + parseInt(o), 0) >>> 0 -} - -function matchesCIDR(ip: string, cidr: string): boolean { - if (!cidr.includes('/')) return ip === cidr - const [net, bits] = cidr.split('/') - const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0 - return (ipToLong(ip) & mask) === (ipToLong(net) & mask) -} +// Unit coverage for the matching primitives, all driving the SHIPPED functions. +// This file used to carry local copies of `ipToLong`/`matchesCIDR`/`getRootDomain` +// and a re-implementation of the ladder. Every one of those copies eventually +// disagreed with the real thing — the ladder after G-B1/G-B3, the root-domain +// rule after G-B2, the CIDR matcher after G-A5 — so none of them are left. +// Exhaustive v6/v4 matcher coverage lives in `ip-match.test.ts`. function matchesDomain(host: string, pattern: string): boolean { if (pattern.startsWith('*.')) { @@ -20,37 +17,31 @@ function matchesDomain(host: string, pattern: string): boolean { return host === pattern } -function getRootDomain(host: string): string { - const parts = host.split('.') - if (parts.length <= 2) return host - return parts.slice(-2).join('.') -} - describe('CIDR matching', () => { it('matches exact IP', () => { - expect(matchesCIDR('10.0.0.1', '10.0.0.1')).toBe(true) - expect(matchesCIDR('10.0.0.2', '10.0.0.1')).toBe(false) + expect(ipInCIDR('10.0.0.1', '10.0.0.1')).toBe(true) + expect(ipInCIDR('10.0.0.2', '10.0.0.1')).toBe(false) }) it('matches /24 range', () => { - expect(matchesCIDR('192.168.1.1', '192.168.1.0/24')).toBe(true) - expect(matchesCIDR('192.168.1.254', '192.168.1.0/24')).toBe(true) - expect(matchesCIDR('192.168.2.1', '192.168.1.0/24')).toBe(false) + expect(ipInCIDR('192.168.1.1', '192.168.1.0/24')).toBe(true) + expect(ipInCIDR('192.168.1.254', '192.168.1.0/24')).toBe(true) + expect(ipInCIDR('192.168.2.1', '192.168.1.0/24')).toBe(false) }) it('matches /16 range', () => { - expect(matchesCIDR('10.10.5.1', '10.10.0.0/16')).toBe(true) - expect(matchesCIDR('10.11.0.1', '10.10.0.0/16')).toBe(false) + expect(ipInCIDR('10.10.5.1', '10.10.0.0/16')).toBe(true) + expect(ipInCIDR('10.11.0.1', '10.10.0.0/16')).toBe(false) }) it('matches /8 range', () => { - expect(matchesCIDR('10.255.255.255', '10.0.0.0/8')).toBe(true) - expect(matchesCIDR('11.0.0.1', '10.0.0.0/8')).toBe(false) + expect(ipInCIDR('10.255.255.255', '10.0.0.0/8')).toBe(true) + expect(ipInCIDR('11.0.0.1', '10.0.0.0/8')).toBe(false) }) it('matches /32 single host', () => { - expect(matchesCIDR('10.0.0.1', '10.0.0.1/32')).toBe(true) - expect(matchesCIDR('10.0.0.2', '10.0.0.1/32')).toBe(false) + expect(ipInCIDR('10.0.0.1', '10.0.0.1/32')).toBe(true) + expect(ipInCIDR('10.0.0.2', '10.0.0.1/32')).toBe(false) }) }) @@ -74,84 +65,68 @@ describe('domain matching', () => { }) }) -describe('root domain extraction', () => { +// Was a local last-two-labels copy. That rule shipped as the G-B2 defect, so the +// duplicate is gone and these now pin the real derivation. Full coverage of the +// suffix table lives in `public-suffix.test.ts`. +describe('registrable domain extraction', () => { it('returns same for 2-part domain', () => { - expect(getRootDomain('example.com')).toBe('example.com') + expect(getRegistrableDomain('example.com')).toBe('example.com') }) it('extracts root from subdomain', () => { - expect(getRootDomain('api.example.com')).toBe('example.com') + expect(getRegistrableDomain('api.example.com')).toBe('example.com') }) it('extracts root from deep subdomain', () => { - expect(getRootDomain('a.b.c.example.com')).toBe('example.com') + expect(getRegistrableDomain('a.b.c.example.com')).toBe('example.com') + }) + + it('does not stop at a multi-label public suffix', () => { + expect(getRegistrableDomain('a.b.c.example.co.uk')).toBe('example.co.uk') }) }) -describe('scope filtering logic', () => { - const IP_RE = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/ - - function checkScope( - target: string, - targets: string[], - excludeTargets: string[] = [] - ): { inScope: boolean; violation: boolean } { - if (targets.length === 0) return { inScope: true, violation: false } - - const isExcluded = excludeTargets.some((ex) => - IP_RE.test(target) ? matchesCIDR(target, ex) : matchesDomain(target, ex) - ) - if (isExcluded) return { inScope: false, violation: true } - - const isInScope = targets.some((t) => - IP_RE.test(target) ? matchesCIDR(target, t) : matchesDomain(target, t) - ) - if (isInScope) return { inScope: true, violation: false } - - const isIP = IP_RE.test(target) - if (!isIP) { - const targetRoot = getRootDomain(target) - const scopeRoots = new Set() - for (const t of targets) { - if (IP_RE.test(t) || t.includes('/')) continue - const domain = t.startsWith('*.') ? t.slice(2) : t - scopeRoots.add(getRootDomain(domain)) - } - if (!scopeRoots.has(targetRoot)) { - return { inScope: false, violation: false } - } - } - - return { inScope: false, violation: true } - } +describe('scope filtering logic — the real classifyDistance', () => { + const violates = (d: string): boolean => d === 'excluded' || d.startsWith('adjacent_') it('everything in scope when no targets configured', () => { - expect(checkScope('anything.com', []).inScope).toBe(true) + expect(classifyDistance('anything.com', { targets: [], excludeTargets: [] })).toBe('in_scope') }) it('in-scope target matches', () => { - expect(checkScope('10.0.0.1', ['10.0.0.0/24']).inScope).toBe(true) + expect(classifyDistance('10.0.0.1', { targets: ['10.0.0.0/24'], excludeTargets: [] })).toBe('in_scope') + }) + + // Was 'out-of-scope IP is a violation'. A written CIDR states its own + // boundary, so its complement is unrelated (D3), not adjacent (D2) — G-B3. + it('an IP outside a stated CIDR is unrelated, not a violation', () => { + const d = classifyDistance('10.0.0.1', { targets: ['192.168.1.0/24'], excludeTargets: [] }) + expect(d).toBe('unrelated') + expect(violates(d)).toBe(false) }) - it('out-of-scope IP is a violation', () => { - const result = checkScope('10.0.0.1', ['192.168.1.0/24']) - expect(result.inScope).toBe(false) - expect(result.violation).toBe(true) + it('an IP in the same segment as a single-IP entry IS a violation — G-B1', () => { + const d = classifyDistance('192.168.1.55', { targets: ['192.168.1.10'], excludeTargets: [] }) + expect(d).toBe('adjacent_subnet') + expect(violates(d)).toBe(true) }) it('unrelated domain is NOT a violation', () => { - const result = checkScope('google.com', ['*.example.com']) - expect(result.violation).toBe(false) + expect(classifyDistance('google.com', { targets: ['*.example.com'], excludeTargets: [] })).toBe('unrelated') }) it('same-root out-of-scope IS a violation', () => { - const result = checkScope('other.example.com', ['*.app.example.com']) - expect(result.violation).toBe(true) + const d = classifyDistance('other.example.com', { targets: ['*.app.example.com'], excludeTargets: [] }) + expect(d).toBe('adjacent_domain') + expect(violates(d)).toBe(true) }) it('excluded target is a violation', () => { - const result = checkScope('internal.example.com', ['*.example.com'], ['internal.example.com']) - expect(result.inScope).toBe(false) - expect(result.violation).toBe(true) + const d = classifyDistance('internal.example.com', { + targets: ['*.example.com'], + excludeTargets: ['internal.example.com'] + }) + expect(d).toBe('excluded') + expect(violates(d)).toBe(true) }) }) diff --git a/test/scope-provenance.test.ts b/test/scope-provenance.test.ts new file mode 100644 index 00000000..89874581 --- /dev/null +++ b/test/scope-provenance.test.ts @@ -0,0 +1,91 @@ +// G-D2. `ALERT-ROLES.md` D.3 lists "the scope is correct and legible, sourced +// from the authorisation document rather than typed" as RedLog's *before* +// contribution — but `scopeFile` recorded a path and nothing else. The +// adherence report (G-D1) states "judged against this scope"; without a digest +// that scope is unattributed exactly where the claim needs backing. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import fs from 'fs' +import os from 'os' +import path from 'path' +import crypto from 'crypto' +import { readScopeFile, loadScopeFile } from '../src/core/config' + +let dir = '' +const write = (name: string, body: string): string => { + const p = path.join(dir, name) + fs.writeFileSync(p, body) + return p +} + +beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'redlog-scopefile-')) }) +afterEach(() => fs.rmSync(dir, { recursive: true, force: true })) + +describe('readScopeFile — provenance', () => { + it('digests the file BYTES, so a reviewer can recompute it', () => { + const body = '10.0.0.0/8\n*.example.com\n' + const p = write('scope.txt', body) + const { provenance } = readScopeFile(p, 1_700_000_000_000) + expect(provenance.digest).toBe(crypto.createHash('sha256').update(body).digest('hex')) + expect(provenance.bytes).toBe(Buffer.byteLength(body)) + expect(provenance.path).toBe(p) + expect(provenance.loadedAt).toBe(1_700_000_000_000) + }) + + it('a byte-identical file digests identically, a changed one does not', () => { + const a = readScopeFile(write('a.txt', '10.0.0.0/8\n')).provenance + const b = readScopeFile(write('b.txt', '10.0.0.0/8\n')).provenance + const c = readScopeFile(write('c.txt', '10.0.0.0/8\n# note\n')).provenance + expect(a.digest).toBe(b.digest) + expect(a.digest).not.toBe(c.digest) + }) + + it('records the mtime, so a file edited in place is visible', () => { + const p = write('scope.txt', '10.0.0.0/8\n') + expect(readScopeFile(p).provenance.modifiedAt).toBeGreaterThan(0) + }) + + it('counts the entries the parser actually extracted', () => { + const p = write('scope.txt', '# a comment\n10.0.0.0/8\n\n*.example.com\n') + const r = readScopeFile(p) + expect(r.targets).toEqual(['10.0.0.0/8', '*.example.com']) + expect(r.provenance.entries).toBe(2) + }) + + // The silent failure this exists to catch: a scope file that yields nothing + // contributes no targets, and "scope active" reads exactly the same. + it('a file that parses to zero entries is visible as zero, not as absent', () => { + const p = write('scope.txt', '# only comments\n\n') + const r = readScopeFile(p) + expect(r.targets).toEqual([]) + expect(r.provenance.entries).toBe(0) + expect(r.provenance.error).toBeUndefined() + expect(r.provenance.digest).toBeTruthy() // it WAS read — that is the point + }) + + it('an unreadable file says so instead of looking like an empty one', () => { + const r = readScopeFile(path.join(dir, 'does-not-exist.txt')) + expect(r.targets).toEqual([]) + expect(r.provenance.error).toBeTruthy() + expect(r.provenance.digest).toBe('') + }) + + it('parses a Burp JSON scope and counts what it got', () => { + const p = write('burp.json', JSON.stringify({ target: { scope: [{ host: '^example\\\\.com$' }, { host: '^api\\\\.example\\\\.com$' }] } })) + const r = readScopeFile(p) + expect(r.provenance.entries).toBe(2) + expect(r.targets).toHaveLength(2) + }) + + it('loadScopeFile stays the targets-only view of the same read', () => { + const p = write('scope.txt', '10.0.0.0/8\n*.example.com\n') + expect(loadScopeFile(p)).toEqual(readScopeFile(p).targets) + }) + + it('a malformed JSON scope degrades to empty rather than throwing', () => { + const p = write('broken.json', '{ not json') + const r = readScopeFile(p) + expect(r.targets).toEqual([]) + expect(r.provenance.digest).toBeTruthy() + }) +}) diff --git a/test/scope-sanitize-plan.test.ts b/test/scope-sanitize-plan.test.ts new file mode 100644 index 00000000..a62af81b --- /dev/null +++ b/test/scope-sanitize-plan.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest' +import { classifyTarget } from '../src/core/scope-monitor' +import { planScopeSanitize, resolveEventTarget, type SanitizeCandidate } from '../src/core/scope-sanitize-plan' +import type { ScopeVerdict } from '../src/core/artifact-pin' + +// Scope-aware sanitize planner (SPEC-SCOPE-AWARE-LIFECYCLE.md Part B). The pure +// decision behind a client-deliverable export: which events' bodies to strip. +// The two rules that matter: unknown is never auto-stripped (A2), and the plan +// covers the io sidecar body, not just inline fields (A1 / §3). + +describe('classifyTarget (pure, no side effects)', () => { + const cfg = { targets: ['*.example.com', '10.0.0.0/24'], excludeTargets: ['secret.example.com'] } + it('classifies in-scope, excluded, and out-of-scope', () => { + expect(classifyTarget('app.example.com', cfg)).toBe('in_scope') + expect(classifyTarget('10.0.0.5', cfg)).toBe('in_scope') + expect(classifyTarget('secret.example.com', cfg)).toBe('excluded') + expect(classifyTarget('evil.com', cfg)).toBe('out_of_scope') + }) + it('is unknown for an absent target, in_scope when no scope is set', () => { + expect(classifyTarget('', cfg)).toBe('unknown') + expect(classifyTarget(null, cfg)).toBe('unknown') + expect(classifyTarget('anything.com', { targets: [], excludeTargets: [] })).toBe('in_scope') + }) +}) + +describe('resolveEventTarget', () => { + it('prefers explicit target, falls back to host/dest_host/detectedTarget, then url host', () => { + expect(resolveEventTarget({ id: '1', targetId: 'a.com', data: {} })).toBe('a.com') + expect(resolveEventTarget({ id: '2', data: { host: 'b.com' } })).toBe('b.com') + expect(resolveEventTarget({ id: '3', data: { url: 'https://c.com/x?q=1' } })).toBe('c.com') + expect(resolveEventTarget({ id: '4', data: {} })).toBeNull() + }) +}) + +const classify = (targets: string[], excl: string[] = []) => + (t: string | null): ScopeVerdict => classifyTarget(t, { targets, excludeTargets: excl }) + +describe('planScopeSanitize', () => { + const events: SanitizeCandidate[] = [ + { id: 'in', targetId: 'app.example.com', data: { response_preview: 'in-scope body', io: { response: { ref: 'a'.repeat(64) } } } }, + { id: 'out', targetId: 'evil.com', data: { response_preview: 'leaked body', io: { response: { ref: 'b'.repeat(64) } } } }, + { id: 'excl', targetId: 'secret.example.com', data: { request_body_preview: 'excluded body' } }, + { id: 'unk', data: { output: 'no target here' } }, + { id: 'empty', targetId: 'evil.com', data: { status: 200 } } // nothing to strip + ] + const plan = planScopeSanitize(events, classify(['*.example.com'], ['secret.example.com'])) + + it('sanitizes out-of-scope and excluded events, keeps in-scope', () => { + expect(plan.toSanitize.map((i) => i.eventId).sort()).toEqual(['excl', 'out']) + expect(plan.keptInScope).toBe(1) + }) + + it('never auto-sanitizes unknown-target events — flags them instead (A2)', () => { + expect(plan.unknown.map((i) => i.eventId)).toEqual(['unk']) + expect(plan.toSanitize.find((i) => i.eventId === 'unk')).toBeUndefined() + }) + + it('covers the io sidecar body, not just inline fields (A1 / §3)', () => { + const out = plan.toSanitize.find((i) => i.eventId === 'out')! + expect(out.fields).toContain('response_preview') + expect(out.ioRefs).toEqual(['b'.repeat(64)]) + }) + + it('omits events with no strippable content', () => { + expect(plan.items.find((i) => i.eventId === 'empty')).toBeUndefined() + }) + + it('leaves in-scope bodies untouched (fields listed but action=keep)', () => { + const inItem = plan.items.find((i) => i.eventId === 'in')! + expect(inItem.action).toBe('keep') + }) +}) diff --git a/test/scope-sanitize.test.ts b/test/scope-sanitize.test.ts new file mode 100644 index 00000000..b52d5d9b --- /dev/null +++ b/test/scope-sanitize.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import fs from 'fs' +import path from 'path' +import os from 'os' +import crypto from 'crypto' +import { scopeRedactionPlaceholder } from '../src/core/scope-sanitize' + +// Scope-aware sanitize execution (SPEC-SCOPE-AWARE-LIFECYCLE.md Part B). The +// placeholder is pure and always testable; the orchestrator + export swap are +// DB-backed and guarded (skip when better-sqlite3 isn't built for this Node). + +describe('scopeRedactionPlaceholder (pure)', () => { + it('names the touched host while removing content (A1)', () => { + expect(scopeRedactionPlaceholder('out_of_scope', 'evil.com')).toBe('[redacted: out-of-scope — evil.com]') + expect(scopeRedactionPlaceholder('excluded', 'secret.example.com')).toBe('[redacted: excluded target — secret.example.com]') + expect(scopeRedactionPlaceholder('unknown', null)).toBe('[redacted: unclassified target]') + }) + it('is empty for in_scope (never sanitized)', () => { + expect(scopeRedactionPlaceholder('in_scope', 'app.example.com')).toBe('') + }) +}) + +// --- DB-backed round trip --- +let initDB: typeof import('../src/core/db/index').initDB +let closeDB: typeof import('../src/core/db/index').closeDB +let insertEvent: typeof import('../src/core/db/events').insertEvent +let queryEvents: typeof import('../src/core/db/events').queryEvents +let putBody: typeof import('../src/core/io-store').putBody +let runScopeSanitize: typeof import('../src/core/scope-sanitize').runScopeSanitize +let getSanitizedIo: typeof import('../src/core/scope-sanitize').getSanitizedIo +let getSanitizedFields: typeof import('../src/core/sanitize').getSanitizedFields +let exportBundle: typeof import('../src/core/bundle-export').exportBundle +let classifyTarget: typeof import('../src/core/scope-monitor').classifyTarget +let dbAvailable = false +try { + initDB = (await import('../src/core/db/index')).initDB + closeDB = (await import('../src/core/db/index')).closeDB + insertEvent = (await import('../src/core/db/events')).insertEvent + queryEvents = (await import('../src/core/db/events')).queryEvents + putBody = (await import('../src/core/io-store')).putBody + const ss = await import('../src/core/scope-sanitize') + runScopeSanitize = ss.runScopeSanitize; getSanitizedIo = ss.getSanitizedIo + getSanitizedFields = (await import('../src/core/sanitize')).getSanitizedFields + exportBundle = (await import('../src/core/bundle-export')).exportBundle + classifyTarget = (await import('../src/core/scope-monitor')).classifyTarget + dbAvailable = true +} catch { /* native module unavailable */ } +const describeDB = dbAvailable ? describe : describe.skip + +const OPTS = { engagementId: 'eng', operatorId: 'op' } +const SCOPE = { targets: ['*.example.com'], excludeTargets: [] } +const classify = (t: string | null) => classifyTarget(t, SCOPE) + +describeDB('runScopeSanitize', () => { + let dir: string + beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'redlog-ss-')); initDB(dir) }) + afterEach(() => { closeDB(); fs.rmSync(dir, { recursive: true, force: true }) }) + + const seedHttp = (target: string, bodyText: string): string => { + const { ref } = putBody(dir, Buffer.from(bodyText, 'utf8')) + insertEvent('scanner', { + subtype: 'http_response', url: `http://${target}/x`, host: target, status: 200, + response_preview: bodyText.slice(0, 100), + io: { response: { ref, len: bodyText.length, sha256: ref } } + }, { ...OPTS, targetId: target }) + return ref + } + + it('sanitizes out-of-scope inline fields + io body, keeps in-scope (A1)', () => { + const inRef = seedHttp('app.example.com', 'IN SCOPE BODY '.repeat(2000)) + const outRef = seedHttp('evil.com', 'OUT OF SCOPE SECRET '.repeat(2000)) + const events = queryEvents({ limit: 100 }) + .map((e) => ({ id: e.id, targetId: e.targetId, data: e.data })) + const res = runScopeSanitize({ events, classify, ...OPTS }) + + expect(res.sanitizedFields).toBeGreaterThanOrEqual(1) + expect(res.sanitizedIoBodies).toBe(1) + expect(res.sanitizedEventId).toBeTruthy() + + // out-of-scope io body has a redacted replacement; in-scope one does not + const io = getSanitizedIo() + expect(io.has(outRef)).toBe(true) + expect(io.has(inRef)).toBe(false) + expect(io.get(outRef)!.value).toContain('out-of-scope') + // replacement digest is the sha of the placeholder, not the original + expect(io.get(outRef)!.replacementSha).toBe(crypto.createHash('sha256').update(io.get(outRef)!.value).digest('hex')) + }) + + it('never sanitizes unknown-target events by default (A2)', () => { + seedHttp('evil.com', 'body '.repeat(2000)) + insertEvent('shell', { subtype: 'command_end', command: 'ls', output: 'X'.repeat(200), redactions: [] }, OPTS) // no target + const events = queryEvents({ limit: 100 }) + .map((e) => ({ id: e.id, targetId: e.targetId, data: e.data })) + const res = runScopeSanitize({ events, classify, ...OPTS }) + expect(res.plan.unknown.length).toBeGreaterThanOrEqual(1) + // the unknown shell event is flagged, not sanitized + expect(res.plan.toSanitize.every((i) => i.scope !== 'unknown')).toBe(true) + }) + + it('client-deliverable export serves the redacted io body; verify would see it as sanitized', () => { + const outRef = seedHttp('evil.com', 'LEAKED '.repeat(3000)) + const { outDir, manifest } = exportBundle('eng', { + outRoot: path.join(dir, 'exp'), profile: 'client-deliverable', scope: SCOPE, operatorId: 'op' + }) + // the bundle io file exists under the ORIGINAL name but holds the replacement + const bundled = fs.readFileSync(path.join(outDir, 'io', `${outRef}.bin`), 'utf8') + expect(bundled).toContain('out-of-scope') + // manifest sha == sha of the replacement bytes (not the original) + const entry = manifest.files.find((f) => f.path === `io/${outRef}.bin`)! + expect(entry.sha256).toBe(crypto.createHash('sha256').update(bundled).digest('hex')) + // and a system.sanitized event recorded the swap (proves it's audited) + const events = fs.readFileSync(path.join(outDir, 'events.jsonl'), 'utf8').trim().split('\n').map((l) => JSON.parse(l)) + const san = events.find((e) => e.agent_type === 'system' && JSON.parse(e.data).subtype === 'sanitized') + expect(san, 'system.sanitized event in bundle').toBeTruthy() + expect(JSON.parse(san.data).io_replacements.some((r: { ref: string }) => r.ref === outRef)).toBe(true) + }) +}) diff --git a/test/scope-violation-event.test.ts b/test/scope-violation-event.test.ts new file mode 100644 index 00000000..39cafeda --- /dev/null +++ b/test/scope-violation-event.test.ts @@ -0,0 +1,76 @@ +// The seam between G-B4 (closed `reason` vocabulary + `authority`) and G-C2 +// (the deconfliction feed tiers on them). `scope-monitor-behaviour.test.ts` +// deliberately runs without an operatorId so `recordViolation` short-circuits +// before touching the DB — which means nothing there proves the event DATA is +// actually stamped. If it is not, the whole G-C2 fix is inert: the blue team +// gets an inference with no label on it. + +import { describe, it, expect, beforeEach, vi } from 'vitest' + +const inserted: Array<{ agentType: string; data: Record }> = [] + +vi.mock('../src/core/db/events', () => ({ + insertEvent: (agentType: string, data: Record) => { + inserted.push({ agentType, data }) + return { id: 'e1', agentType, data } + } +})) +vi.mock('../src/core/event-bus', () => ({ eventBus: { publish: () => {} } })) + +const { ScopeMonitor } = await import('../src/core/scope-monitor') + +describe('scope_violation event payload', () => { + let m: InstanceType + + beforeEach(() => { + inserted.length = 0 + m = new ScopeMonitor() + m.configure({ + targets: ['192.168.1.10', '*.app.example.com'], + excludeTargets: ['dc01.app.example.com'], + operatorId: 'op-1', + alertFloor: 'adjacent' + }) + }) + + it('stamps an excluded target as a fact', () => { + m.checkTarget('dc01.app.example.com', 'nmap dc01.app.example.com') + expect(inserted).toHaveLength(1) + expect(inserted[0].data.subtype).toBe('scope_violation') + expect(inserted[0].data.reason).toBe('excluded_target') + expect(inserted[0].data.authority).toBe('fact') + }) + + it('stamps a same-subnet near-miss as an inference, named', () => { + m.checkTarget('192.168.1.55', 'nmap 192.168.1.55') + expect(inserted[0].data.reason).toBe('adjacent_subnet') + expect(inserted[0].data.authority).toBe('inferred') + }) + + it('stamps a same-domain near-miss as an inference, named', () => { + m.checkTarget('vpn.example.com', 'curl vpn.example.com') + expect(inserted[0].data.reason).toBe('adjacent_domain') + expect(inserted[0].data.authority).toBe('inferred') + }) + + it('emits nothing for an unrelated target — D3 is counted, not forwarded', () => { + m.checkTarget('8.8.8.8', 'dig @8.8.8.8 example.com') + expect(inserted).toHaveLength(0) + expect(m.getUnrelatedCount()).toBe(1) + }) + + it('emits nothing in scope', () => { + m.checkTarget('api.app.example.com', 'curl api.app.example.com') + expect(inserted).toHaveLength(0) + }) + + // The fact tier is not silenceable, so a fact-floored feed still gets it. + it('still emits the fact-tier violation at the lowest floor', () => { + m.configure({ alertFloor: 'excluded_only' }) + m.checkTarget('192.168.1.55', 'nmap 192.168.1.55') + expect(inserted).toHaveLength(0) + m.checkTarget('dc01.app.example.com', 'nmap dc01.app.example.com') + expect(inserted).toHaveLength(1) + expect(inserted[0].data.authority).toBe('fact') + }) +}) diff --git a/test/screenshot-options.test.ts b/test/screenshot-options.test.ts new file mode 100644 index 00000000..4a34649f --- /dev/null +++ b/test/screenshot-options.test.ts @@ -0,0 +1,195 @@ +// screenshot.{quality,intervalSec} — the auto-capture cadence and the JPEG +// quality it writes at. intervalSec is the one config number that can quietly +// fill a project directory, and it has three distinct off-switches (0, no +// operator, paused recording), so each one gets an assertion. + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import fs from 'fs' +import os from 'os' +import path from 'path' + +let projectDir = '' +const inserted: Array> = [] +const jpegCalls: number[] = [] +/** Per-pixel value the fake screen renders at — change it to "move the screen". */ +let frame = 10 + +function fakeImage(): unknown { + const img: Record = { + toJPEG: (q: number) => { jpegCalls.push(q); return Buffer.from(`jpeg-${frame}`) }, + resize: () => ({ + // 9×8 BGRA, every pixel a ramp seeded from `frame` so two different + // frames are perceptually far apart and two identical ones are not. + toBitmap: () => Buffer.from(Array.from({ length: 9 * 8 * 4 }, (_, i) => (i * frame) % 256)) + }) + } + return img +} + +vi.mock('electron', () => ({ + screen: { getPrimaryDisplay: () => ({ size: { width: 1920, height: 1080 } }) }, + desktopCapturer: { getSources: async () => [{ thumbnail: fakeImage() }] } +})) + +vi.mock('../src/core/db/index', () => ({ getProjectDir: () => projectDir })) + +vi.mock('../src/core/db/events', () => ({ + insertEvent: (type: string, data: Record) => { + inserted.push({ type, ...data }) + return { id: `e${inserted.length}`, agentType: type, data } + } +})) + +const { ScreenshotAgent } = await import('../src/main/services/screenshot-agent') +const { eventBus } = await import('../src/core/event-bus') + +let agent: InstanceType + +beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'redlog-shot-')) + inserted.length = 0 + jpegCalls.length = 0 + frame = 10 + if (eventBus.paused) eventBus.resume() + agent = new ScreenshotAgent() +}) + +afterEach(() => { + agent.stop() + fs.rmSync(projectDir, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +describe('screenshot.intervalSec — periodic auto-capture', () => { + function scheduledMs(opts: { intervalSec?: number; operatorId?: string }): number | null { + const spy = vi.spyOn(globalThis, 'setInterval') + agent.configure({ engagementId: 'e', operatorId: 'op', ...opts }) + const call = spy.mock.calls[spy.mock.calls.length - 1] + spy.mockRestore() + return call ? (call[1] as number) : null + } + + it('0 (the default) schedules nothing', () => { + expect(scheduledMs({ intervalSec: 0 })).toBeNull() + }) + + it('30 schedules a 30s loop', () => { + expect(scheduledMs({ intervalSec: 30 })).toBe(30_000) + }) + + it('floors a fractional interval instead of producing a fractional timer', () => { + expect(scheduledMs({ intervalSec: 12.9 })).toBe(12_000) + }) + + it('treats a negative interval as off', () => { + expect(scheduledMs({ intervalSec: -5 })).toBeNull() + }) + + it('will not schedule without an operator id — every frame needs attribution', () => { + const spy = vi.spyOn(globalThis, 'setInterval') + new ScreenshotAgent().configure({ engagementId: 'e', intervalSec: 30 }) + expect(spy).not.toHaveBeenCalled() + }) + + it('setting it back to 0 cancels the running loop', () => { + agent.configure({ engagementId: 'e', operatorId: 'op', intervalSec: 30 }) + const clear = vi.spyOn(globalThis, 'clearInterval') + agent.configure({ intervalSec: 0 }) + expect(clear).toHaveBeenCalled() + }) + + it('re-configuring the interval replaces the old timer rather than stacking one', () => { + agent.configure({ engagementId: 'e', operatorId: 'op', intervalSec: 30 }) + const clear = vi.spyOn(globalThis, 'clearInterval') + const set = vi.spyOn(globalThis, 'setInterval') + agent.configure({ intervalSec: 10 }) + expect(clear).toHaveBeenCalledTimes(1) + expect(set.mock.calls[set.mock.calls.length - 1][1]).toBe(10_000) + }) +}) + +describe('screenshot.quality — what reaches the JPEG encoder', () => { + it('defaults to 85', async () => { + agent.configure({ engagementId: 'e', operatorId: 'op' }) + await agent.captureNow('manual') + expect(jpegCalls).toEqual([85]) + }) + + it('passes a configured quality straight through', async () => { + agent.configure({ engagementId: 'e', operatorId: 'op', quality: 40 }) + await agent.captureNow('manual') + expect(jpegCalls).toEqual([40]) + }) + + it('ignores 0 — a zero-quality capture is not evidence', async () => { + agent.configure({ engagementId: 'e', operatorId: 'op', quality: 0 }) + await agent.captureNow('manual') + expect(jpegCalls).toEqual([85]) + }) +}) + +describe('captureNow — what lands on disk and in the chain', () => { + beforeEach(() => agent.configure({ engagementId: 'e', operatorId: 'op' })) + + it('writes the jpeg and records its size, dimensions and digest', async () => { + const p = await agent.captureNow('manual') + expect(p).toBeTruthy() + expect(fs.existsSync(p as string)).toBe(true) + const ev = inserted[0] + expect(ev.trigger).toBe('manual') + expect(ev.width).toBe(1920) + expect(ev.height).toBe(1080) + expect(ev.size).toBeGreaterThan(0) + expect(ev.sha256).toMatch(/^[0-9a-f]{64}$/) + }) + + it('files land under the project screenshots/ directory', async () => { + const p = await agent.captureNow('manual') as string + expect(path.dirname(p)).toBe(path.join(projectDir, 'screenshots')) + }) + + it('does nothing at all without an operator id', async () => { + expect(await new ScreenshotAgent().captureNow('manual')).toBeNull() + expect(inserted).toHaveLength(0) + }) + + it('links a cause event id when one is supplied', async () => { + await agent.captureNow('manual', 'marker-1') + expect(inserted[0]._causes).toEqual(['marker-1']) + }) +}) + +describe('dedup + pause gating for automatic captures', () => { + beforeEach(() => agent.configure({ engagementId: 'e', operatorId: 'op' })) + + it('an unchanged screen is captured once, not once per tick', async () => { + await agent.captureNow('periodic') + await agent.captureNow('periodic') + expect(inserted).toHaveLength(1) + }) + + it('a changed screen is captured again', async () => { + await agent.captureNow('periodic') + frame = 200 + await agent.captureNow('periodic') + expect(inserted).toHaveLength(2) + }) + + it('a manual capture always lands, even on an identical screen', async () => { + await agent.captureNow('manual') + await agent.captureNow('manual') + expect(inserted).toHaveLength(2) + }) + + it('paused recording suspends periodic capture', async () => { + eventBus.pause('ui') + expect(await agent.captureNow('periodic')).toBeNull() + expect(inserted).toHaveLength(0) + }) + + it('a manual capture still works while paused — user intent overrides', async () => { + eventBus.pause('ui') + expect(await agent.captureNow('manual')).toBeTruthy() + expect(inserted).toHaveLength(1) + }) +}) diff --git a/test/settings-interaction.test.tsx b/test/settings-interaction.test.tsx new file mode 100644 index 00000000..4c90b236 --- /dev/null +++ b/test/settings-interaction.test.tsx @@ -0,0 +1,366 @@ +// @vitest-environment jsdom +// +// Settings is 2,700 lines and, until now, had exactly one assertion against it: +// "it mounts" (`renderer-smoke`). Every option in `config.yaml` is reachable +// only through this component, so an option whose control writes the wrong key — +// or coerces a value differently from the engine that consumes it — is invisible +// to the rest of the suite. +// +// Each test here drives a real control and reads what `config.save` was handed. +// Auto-save is debounced 350 ms; the Save button is the documented "flush now" +// escape hatch, so it is what these tests press. + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, cleanup, screen, fireEvent, waitFor } from '@testing-library/react' +import { I18nProvider } from '../src/renderer/src/i18n' + +// Settings resolves `isMacOS` at module scope, so the platform has to be on the +// bridge BEFORE the import — otherwise the macOS-only controls (Dock icon, +// Wi-Fi SSID) never render and their tests silently pass on an empty DOM. +;(window as unknown as { redlog: unknown }).redlog = { platform: 'darwin' } +const { default: Settings } = await import('../src/renderer/src/components/Settings') + +const unsub = (): void => {} +const saved: Array> = [] +/** URLs the marketplace panel asked the bridge to fetch. */ +const fetched: Array = [] + +const BASE_CONFIG = { + engagement: { id: 'eng', name: 'Engagement' }, + operator: { id: 'op-1', name: 'Operator' }, + network: { + whitelist: ['10.8.0.0/24'], blacklist: ['1.2.3.4'], checkInterval: 60, + providers: [], confirmations: 3, ipMode: 'auto', showWifiName: false, vpnAdapters: [] + }, + scope: { alertFloor: 'adjacent', targets: ['*.app.example.com'], excludeTargets: [], proximityBits: 24, scopeFile: '' }, + screenshot: { quality: 85, intervalSec: 0 }, + overlay: { + showMarkButton: true, showInDock: true, flashOnExposed: true, + scale: 1.0, emphasizeExternalIp: false, passThrough: false, passThroughOpacity: 0.4 + }, + terminal: { maxCastBytes: 52428800 }, + clipboard: { enabled: false, pollMs: 1500, storePreview: false }, + browser: { binary: '', proxy: 'http://127.0.0.1:8080', cdpPort: 9222, isolateProfile: true, ignoreCertErrors: true, startUrl: '', extraArgs: [] }, + redaction: { allowlist: [], denylist: [], entropyThreshold: 4.5, minLength: 20 }, + deconfliction: { enabled: false, url: '', secret: '', events: [], subtypes: [], includeData: false }, + fileWatcher: { enabled: false, watchPaths: [], ignorePatterns: [] }, + processMonitor: { enabled: false, pollMs: 500, ignoreCommands: [] }, + agentTailer: { enabled: true, emitThinking: false }, + marketplace: { defaultRegistryUrl: 'https://example.test/index.json' }, + cloudShare: { endpoint: '', authToken: '' } +} + +function installBridge(): void { + ;(window as unknown as { redlog: unknown }).redlog = { + platform: 'darwin', + config: { + get: async () => structuredClone(BASE_CONFIG), + save: async (c: Record) => { saved.push(structuredClone(c)); return true }, + exportProfile: async () => null, + importProfile: async () => null + }, + hooks: { detect: async () => [], install: async () => ({ success: true, message: '' }), uninstall: async () => ({ success: true, message: '' }) }, + hookConfig: { get: async () => ({ excludedPaths: [], watchPaths: [] }), save: async () => true }, + operators: { list: async () => [{ id: 'op-1', name: 'Operator', isPrimary: true, createdAt: 1, revokedAt: null }] }, + chain: { length: async () => 1, anchors: async () => [], verify: async () => ({ ok: true, anchor: null, currentHead: null }), upgrade: async () => ({ upgraded: 0, scanned: 0 }) }, + mcp: { info: async () => ({ port: 6660, endpoint: 'http://127.0.0.1:6660/mcp', stdioPath: '/x.js', hasToken: false }) }, + cdp: { getTab: async () => ({ url: null, title: null, connected: false }), setPort: async () => true }, + browser: { detect: async () => '/Applications/Chrome', status: async () => ({ running: false }) }, + deconfliction: { get: async () => BASE_CONFIG.deconfliction, test: async () => ({ ok: true, status: 200 }) }, + data: { exportJson: async () => '/tmp/x.json', exportScopeFiltered: async () => '/tmp/y.json', exportBundle: async () => null }, + plugins: { list: async () => [], setEnabled: async () => true, install: async () => ({ ok: true }), uninstall: async () => true }, + marketplace: { + fetchIndex: async (url?: string) => { fetched.push(url); return { ok: true, index: { plugins: [] } } }, + listPublishers: async () => [], + revocations: async () => ({ plugins: [], publishers: [] }), + trustPublisher: async () => ({ ok: true }), + untrustPublisher: async () => ({ ok: true }), + install: async () => ({ ok: true }) + }, + cloudShare: { upload: async () => null }, + updater: { check: async () => null, onStatus: () => unsub }, + events: { onNew: () => unsub }, + project: { active: async () => ({ id: 'p1', name: 'Proj', path: '/tmp/p1', createdAt: 1 }) }, + ip: { getStatus: async () => null, onStatus: () => unsub } + } +} + +/** Render Settings, wait for the config fetch, and open a tab. */ +async function open(tab: 'Capture' | 'Scope' | 'Evidence' | 'OPSEC' | 'Advanced'): Promise { + installBridge() + render() + fireEvent.click(await screen.findByText(tab)) +} + +/** Flush the debounce and return the config handed to `config.save`. */ +async function save(): Promise> { + fireEvent.click(screen.getByText('Save')) + await waitFor(() => expect(saved.length).toBeGreaterThan(0)) + return saved[saved.length - 1] as Record +} + +/** The checkbox belonging to a label, found by the label's text. */ +function toggle(text: string | RegExp): HTMLInputElement { + const label = screen.getByText(text).closest('label') as HTMLElement + return label.querySelector('input[type="checkbox"]') as HTMLInputElement +} + +/** The text input belonging to a ``. */ +function field(text: string | RegExp): HTMLInputElement { + const wrapper = screen.getByText(text).parentElement as HTMLElement + return wrapper.querySelector('input') as HTMLInputElement +} + +beforeEach(() => { saved.length = 0; fetched.length = 0 }) +afterEach(() => { cleanup(); vi.restoreAllMocks() }) + +describe('Scope tab', () => { + // G-C3: the boolean became a three-value floor, because the distance ladder + // is ordered — N booleans would let an operator build incoherent states. + it('the floor selector writes scope.alertFloor', async () => { + await open('Scope') + fireEvent.click(await screen.findByText('Forbidden only')) + expect((await save()).scope.alertFloor).toBe('excluded_only') + }) + + it('the strictest floor is reachable too', async () => { + await open('Scope') + fireEvent.click(await screen.findByText('Everything off-list')) + expect((await save()).scope.alertFloor).toBe('all') + }) + + it('dropping to the lowest floor hides the adjacency width — it has nothing to widen', async () => { + await open('Scope') + expect(await screen.findByText(/Adjacent zone width/)).toBeTruthy() + fireEvent.click(screen.getByText('Forbidden only')) + await waitFor(() => expect(screen.queryByText(/Adjacent zone width/)).toBeNull()) + }) + + // The fact-tier rule made structural: there is no position that silences D1. + it('every floor still alerts on explicitly excluded targets', async () => { + await open('Scope') + expect(await screen.findByText(/Explicitly excluded targets alert at every setting/)).toBeTruthy() + }) + + it('proximityBits writes the value the ScopeMonitor consumes', async () => { + await open('Scope') + fireEvent.change(field(/Adjacent zone width/), { target: { value: '16' } }) + expect((await save()).scope.proximityBits).toBe(16) + }) + + it('proximityBits clamps to the 1–32 CIDR range', async () => { + await open('Scope') + const input = field(/Adjacent zone width/) + fireEvent.change(input, { target: { value: '99' } }) + expect((await save()).scope.proximityBits).toBe(32) + fireEvent.change(input, { target: { value: '0' } }) + expect((await save()).scope.proximityBits).toBe(24) // 0 is junk → default + fireEvent.change(input, { target: { value: '-8' } }) + expect((await save()).scope.proximityBits).toBe(1) + }) + + it('non-numeric input falls back to the default rather than NaN', async () => { + await open('Scope') + fireEvent.change(field(/Adjacent zone width/), { target: { value: 'wide' } }) + expect((await save()).scope.proximityBits).toBe(24) + }) + + it('adding a target appends to scope.targets', async () => { + await open('Scope') + const list = (await screen.findByText(/^Targets/)).parentElement as HTMLElement + fireEvent.change(list.querySelector('input') as HTMLElement, { target: { value: '10.0.0.0/24' } }) + fireEvent.click(list.querySelector('button') as HTMLElement) + expect((await save()).scope.targets).toEqual(['*.app.example.com', '10.0.0.0/24']) + }) + + it('adding an exclusion appends to scope.excludeTargets, not to targets', async () => { + await open('Scope') + const list = (await screen.findByText('Exclude from scope')).parentElement as HTMLElement + fireEvent.change(list.querySelector('input') as HTMLElement, { target: { value: 'dc01.app.example.com' } }) + fireEvent.click(list.querySelector('button') as HTMLElement) + const cfg = await save() + expect(cfg.scope.excludeTargets).toEqual(['dc01.app.example.com']) + expect(cfg.scope.targets).toEqual(['*.app.example.com']) + }) + + it('the scope file path is written verbatim', async () => { + await open('Scope') + fireEvent.change(field(/Load targets from file/), { target: { value: '/eng/scope.txt' } }) + expect((await save()).scope.scopeFile).toBe('/eng/scope.txt') + }) +}) + +describe('OPSEC tab — IP polling', () => { + it('each ipMode button writes its own value', async () => { + await open('OPSEC') + fireEvent.click(await screen.findByText('DNS')) + expect((await save()).network.ipMode).toBe('dns') + fireEvent.click(screen.getByText('HTTP')) + expect((await save()).network.ipMode).toBe('http') + fireEvent.click(screen.getByText(/^Auto/)) + expect((await save()).network.ipMode).toBe('auto') + }) + + it('checkInterval writes a number, not the raw string', async () => { + await open('OPSEC') + fireEvent.change(field('Check interval (seconds)'), { target: { value: '15' } }) + expect((await save()).network.checkInterval).toBe(15) + }) + + it('a non-numeric interval falls back to 60 rather than NaN', async () => { + await open('OPSEC') + fireEvent.change(field('Check interval (seconds)'), { target: { value: 'soon' } }) + expect((await save()).network.checkInterval).toBe(60) + }) + + it('confirmations is floored at 1 — 0 would mean "never confirm"', async () => { + await open('OPSEC') + const input = field(/Readings required/) + fireEvent.change(input, { target: { value: '5' } }) + expect((await save()).network.confirmations).toBe(5) + fireEvent.change(input, { target: { value: '0' } }) + expect((await save()).network.confirmations).toBe(3) + fireEvent.change(input, { target: { value: '-2' } }) + expect((await save()).network.confirmations).toBe(1) + }) + + it('the safe/exposed lists write to whitelist and blacklist respectively', async () => { + await open('OPSEC') + // Located by the entry placeholder: the two labels are long prose sentences, + // the placeholders are the stable handle. + const input = await screen.findByPlaceholderText('e.g. 10.8.0.0/24') + const safe = input.parentElement as HTMLElement + fireEvent.change(input, { target: { value: '203.0.113.0/24' } }) + fireEvent.click(safe.querySelector('button') as HTMLElement) + const cfg = await save() + expect(cfg.network.whitelist).toEqual(['10.8.0.0/24', '203.0.113.0/24']) + expect(cfg.network.blacklist).toEqual(['1.2.3.4']) + }) + + it('removing an entry drops only that one', async () => { + await open('OPSEC') + const exposed = (await screen.findByPlaceholderText('e.g. 114.24.97.0/24')) + .parentElement?.parentElement as HTMLElement + fireEvent.click(exposed.querySelector('span button') as HTMLElement) + const cfg = await save() + expect(cfg.network.blacklist).toEqual([]) + expect(cfg.network.whitelist).toEqual(['10.8.0.0/24']) + }) +}) + +describe('OPSEC tab — HUD appearance', () => { + it('the flash toggle writes overlay.flashOnExposed', async () => { + await open('OPSEC') + fireEvent.click(toggle('Flash HUD when IP exposed')) + expect((await save()).overlay.flashOnExposed).toBe(false) + }) + + it('each scale button writes its documented value', async () => { + await open('OPSEC') + fireEvent.click(await screen.findByText('Small')) + expect((await save()).overlay.scale).toBe(0.85) + fireEvent.click(screen.getByText('Large')) + expect((await save()).overlay.scale).toBe(1.25) + }) + + it('the emphasis toggle writes overlay.emphasizeExternalIp', async () => { + await open('OPSEC') + fireEvent.click(toggle('Emphasize external IP')) + expect((await save()).overlay.emphasizeExternalIp).toBe(true) + }) + + it('the opacity slider only appears once click-through is on', async () => { + await open('OPSEC') + expect(screen.queryByText(/Opacity while click-through/)).toBeNull() + fireEvent.click(toggle(/Click-through mode/)) + expect(await screen.findByText(/Opacity while click-through/)).toBeTruthy() + expect((await save()).overlay.passThrough).toBe(true) + }) + + it('the opacity slider writes a float and shows it as a percentage', async () => { + await open('OPSEC') + fireEvent.click(toggle(/Click-through mode/)) + const row = (await screen.findByText(/Opacity while click-through/)).parentElement as HTMLElement + fireEvent.change(row.querySelector('input[type="range"]') as HTMLElement, { target: { value: '0.25' } }) + expect((await save()).overlay.passThroughOpacity).toBe(0.25) + expect(row.textContent).toContain('25%') + }) + + it('the mark-button and Dock toggles write their own keys', async () => { + await open('OPSEC') + fireEvent.click(toggle(/Show Mark button/)) + fireEvent.click(toggle(/Keep Dock icon/)) + const cfg = await save() + expect(cfg.overlay.showMarkButton).toBe(false) + expect(cfg.overlay.showInDock).toBe(false) + }) +}) + +describe('Capture tab', () => { + it('JPEG quality writes a number', async () => { + await open('Capture') + fireEvent.change(field(/JPEG quality/), { target: { value: '60' } }) + expect((await save()).screenshot.quality).toBe(60) + }) +}) + +describe('cross-cutting', () => { + it('changing one option leaves every other block untouched', async () => { + await open('Scope') + fireEvent.click(await screen.findByText('Forbidden only')) + const cfg = await save() + expect(cfg.network).toEqual(BASE_CONFIG.network) + expect(cfg.overlay).toEqual(BASE_CONFIG.overlay) + expect(cfg.redaction).toEqual(BASE_CONFIG.redaction) + expect(cfg.deconfliction).toEqual(BASE_CONFIG.deconfliction) + }) + + // F3: a filter hit is a jump button carrying its owning tab, not the control + // itself — clicking it switches tab and clears the filter so the real controls + // show. The new proximityBits label has to be in that index to be findable. + it('the search box finds a setting by name from another tab, and jumps to it', async () => { + await open('Capture') + fireEvent.change(await screen.findByPlaceholderText(/filter settings/i), { target: { value: 'adjacent' } }) + const hit = await screen.findByText('Scope Warnings') + expect((hit.parentElement as HTMLElement).textContent).toContain('Scope') + fireEvent.click(hit) + expect(await screen.findByText(/Adjacent zone width/)).toBeTruthy() + }) +}) + +// marketplace.defaultRegistryUrl was filed as manual-only (G-UI1). It is not +// manual — it is DORMANT: `MARKETPLACE_ENABLED = false` shelves the only panel +// that reads it (the marketplace was judged the one genuine over-build), so the +// setting has no UI consumer at all right now. Recording that here means +// un-shelving the panel trips a test that says "and now this option is live +// again", instead of the option quietly coming back untested. +describe('Advanced ▸ Plugins — the marketplace is shelved', () => { + function expandSection(title: string): void { + const btn = screen.getAllByRole('button') + .find((b) => b.getAttribute('aria-expanded') === 'false' && b.textContent?.includes(title)) + expect(btn, `no collapsed section titled ${title}`).toBeTruthy() + fireEvent.click(btn as HTMLElement) + } + + async function openPlugins(): Promise { + await open('Advanced') + await screen.findByText('Save') + expandSection('Plugins') + } + + it('shows the installed-plugins panel directly, with no marketplace sub-tab', async () => { + await openPlugins() + expect(screen.queryByText('Marketplace')).toBeNull() + }) + + it('the registry URL box — the only consumer of the setting — is not rendered', async () => { + await openPlugins() + expect(screen.queryByPlaceholderText('https://example.test/index.json')).toBeNull() + expect(screen.queryByText('Fetch index')).toBeNull() + }) + + it('nothing fetches a registry while the panel is shelved', async () => { + await openPlugins() + expect(fetched).toHaveLength(0) + }) +}) diff --git a/test/settings-search.test.ts b/test/settings-search.test.ts new file mode 100644 index 00000000..b6fc8af0 --- /dev/null +++ b/test/settings-search.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest' +import { matchGroups } from '../src/renderer/src/lib/settingsSearch' +import type { SettingsGroupIndex } from '../src/renderer/src/lib/settingsSearch' + +// Settings is 8 tabs / 34 FieldGroups / 234 i18n keys with no way to find a +// setting except remembering its tab (UX-BACKLOG F3). matchGroups is the pure +// seam behind the filter box: given the operator's query and a static index of +// every group (tab + title + the labels inside it), it returns the groups whose +// title OR any label contains the query, case-insensitively, in original order. +// An empty query means "no filter" — the whole index passes through untouched so +// the component can fall back to its normal tabbed view. Keeping the matcher +// pure means the "typing 'exclude' finds the scope exclusion list" behaviour is +// written down and tested, not buried in a 2,681-line component. + +const INDEX: SettingsGroupIndex[] = [ + { tab: 'general', groupId: 'engagement', title: 'Engagement', labels: ['ID', 'Name'] }, + { tab: 'scope', groupId: 'excluded', title: 'Excluded Targets', labels: ['Out-of-scope host'] }, + { tab: 'network', groupId: 'polling', title: 'Polling', labels: ['Check interval', 'IP mode'] } +] + +describe('matchGroups', () => { + it('returns the whole index when the query is empty (no filter)', () => { + expect(matchGroups('', INDEX)).toEqual(INDEX) + }) + + it('treats a whitespace-only query as empty and returns everything', () => { + expect(matchGroups(' ', INDEX)).toEqual(INDEX) + }) + + it('matches on the group title', () => { + const hits = matchGroups('polling', INDEX) + expect(hits.map((g) => g.groupId)).toEqual(['polling']) + }) + + it('matches on a field label inside the group', () => { + const hits = matchGroups('interval', INDEX) + expect(hits.map((g) => g.groupId)).toEqual(['polling']) + }) + + it('is case-insensitive over both title and labels', () => { + expect(matchGroups('ENGAGEMENT', INDEX).map((g) => g.groupId)).toEqual(['engagement']) + expect(matchGroups('ip MODE', INDEX).map((g) => g.groupId)).toEqual(['polling']) + }) + + it('surfaces the scope exclusion group regardless of tab (the F3 acceptance case)', () => { + const hits = matchGroups('exclude', INDEX) + expect(hits.map((g) => g.groupId)).toEqual(['excluded']) + expect(hits[0].tab).toBe('scope') + }) + + it('returns [] when nothing matches', () => { + expect(matchGroups('nonexistent-xyz', INDEX)).toEqual([]) + }) + + it('preserves the original order of the index for multi-hit queries', () => { + // "e" appears in Engagement (title), Excluded (title) and Polling + // (label "Check interval") — all three, so order is observable. + const hits = matchGroups('e', INDEX) + expect(hits.map((g) => g.groupId)).toEqual(['engagement', 'excluded', 'polling']) + }) +}) diff --git a/test/shortcuts.test.ts b/test/shortcuts.test.ts new file mode 100644 index 00000000..4b77edd0 --- /dev/null +++ b/test/shortcuts.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest' +import { SHORTCUTS, shortcutsForScope, modKey, type Shortcut } from '../src/renderer/src/lib/shortcuts' + +describe('shortcuts registry', () => { + it('every entry has id/keys/labelKey/scope', () => { + for (const s of SHORTCUTS) { + expect(typeof s.id).toBe('string') + expect(s.id.length).toBeGreaterThan(0) + expect(typeof s.keys).toBe('string') + expect(s.keys.length).toBeGreaterThan(0) + expect(typeof s.labelKey).toBe('string') + expect(s.labelKey.length).toBeGreaterThan(0) + expect(['global', 'timeline']).toContain(s.scope) + } + }) + + it('has no duplicate ids', () => { + const ids = SHORTCUTS.map((s) => s.id) + expect(new Set(ids).size).toBe(ids.length) + }) + + it('covers the real global + timeline shortcuts', () => { + const byId = (id: string): Shortcut | undefined => SHORTCUTS.find((s) => s.id === id) + // global + expect(byId('switch-view')?.scope).toBe('global') + expect(byId('quick-marker')?.scope).toBe('global') + expect(byId('search')?.scope).toBe('global') + expect(byId('pause-recording')?.scope).toBe('global') + // timeline + expect(byId('filter-events')?.scope).toBe('timeline') + expect(byId('toggle-help')?.scope).toBe('timeline') + expect(byId('focus-chain')?.scope).toBe('timeline') + expect(byId('command-palette')?.scope).toBe('timeline') + expect(byId('navigate-events')?.scope).toBe('timeline') + expect(byId('solo-lane')?.scope).toBe('timeline') + expect(byId('drop-marker')?.scope).toBe('timeline') + }) + + it('uses the literal Mod placeholder token, never a resolved symbol', () => { + for (const s of SHORTCUTS) { + expect(s.keys).not.toContain('⌘') + expect(s.keys).not.toContain('Ctrl') + } + expect(byKeys('switch-view')).toBe('Mod+1..9') + expect(byKeys('quick-marker')).toBe('Mod+Shift+M') + expect(byKeys('search')).toBe('Mod+/') + expect(byKeys('pause-recording')).toBe('Mod+.') + expect(byKeys('command-palette')).toBe('Mod+K') + expect(byKeys('filter-events')).toBe('/') + expect(byKeys('toggle-help')).toBe('?') + expect(byKeys('focus-chain')).toBe('f') + expect(byKeys('navigate-events')).toBe('↑/↓') + expect(byKeys('solo-lane')).toBe('Alt+click') + expect(byKeys('drop-marker')).toBe('Right-click') + }) + + function byKeys(id: string): string | undefined { + return SHORTCUTS.find((s) => s.id === id)?.keys + } +}) + +describe('shortcutsForScope', () => { + it("'global' returns only global-scope shortcuts", () => { + const list = shortcutsForScope('global') + expect(list.length).toBeGreaterThan(0) + expect(list.every((s) => s.scope === 'global')).toBe(true) + // no timeline-scope entry leaks in + expect(list.some((s) => s.scope === 'timeline')).toBe(false) + }) + + it("'timeline' returns global + timeline shortcuts", () => { + const list = shortcutsForScope('timeline') + expect(list.some((s) => s.scope === 'global')).toBe(true) + expect(list.some((s) => s.scope === 'timeline')).toBe(true) + // it is a superset of the global scope + expect(list.length).toBe(shortcutsForScope('global').length + SHORTCUTS.filter((s) => s.scope === 'timeline').length) + }) +}) + +describe('modKey', () => { + it("returns ⌘ on darwin", () => { + expect(modKey('darwin')).toBe('⌘') + }) + + it('returns Ctrl on every other platform', () => { + expect(modKey('win32')).toBe('Ctrl') + expect(modKey('linux')).toBe('Ctrl') + expect(modKey('')).toBe('Ctrl') + }) +}) diff --git a/test/split-pane.test.ts b/test/split-pane.test.ts new file mode 100644 index 00000000..6fb51a89 --- /dev/null +++ b/test/split-pane.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest' +import { splitPaneClamp } from '../src/renderer/src/lib/splitPane' + +// The drag seam (DESIGN-SYSTEM §6.2). All bounds live here so the +// component's mousemove handler is a thin wrapper over a tested pure function. +describe('splitPaneClamp — resizable-pane bounds', () => { + // In-range asks pass through untouched. + it('returns px unchanged when within [min, max] and not starving the other pane', () => { + expect(splitPaneClamp(300, 200, 500, 1000)).toBe(300) + }) + + it('clamps to max when px exceeds it', () => { + expect(splitPaneClamp(600, 200, 500, 1000)).toBe(500) + }) + + it('clamps to min when px is below it', () => { + expect(splitPaneClamp(100, 200, 500, 1000)).toBe(200) + }) + + // The other pane must keep `otherMin` px: a 1000px container with otherMin=120 + // caps the resizable pane at 880 even though its own max is 900. + it('never starves the other pane below otherMin', () => { + expect(splitPaneClamp(900, 200, 900, 1000, 120)).toBe(880) + }) + + // Degenerate: container too small to satisfy both — min wins, no inversion. + it('falls back to min when the container cannot satisfy min + otherMin', () => { + expect(splitPaneClamp(300, 200, 500, 250, 120)).toBe(200) + }) + + // Default otherMin (120) applies when omitted. + it('applies the default otherMin of 120', () => { + expect(splitPaneClamp(950, 200, 1000, 1000)).toBe(880) + }) + + // Exact-boundary asks are stable (idempotent at the edges). + it('is stable at the min and max boundaries', () => { + expect(splitPaneClamp(200, 200, 500, 1000)).toBe(200) + expect(splitPaneClamp(500, 200, 500, 1000)).toBe(500) + }) +}) diff --git a/test/target-grouping.test.ts b/test/target-grouping.test.ts new file mode 100644 index 00000000..bd64be2f --- /dev/null +++ b/test/target-grouping.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest' +import { groupByTarget } from '../src/renderer/src/lib/targetGrouping' +import type { EventLike } from '../src/renderer/src/lib/targetGrouping' + +// DESIGN-PRINCIPLES §8/§9 reorganise the timeline around a target axis (source-type +// demotes to a filter, TargetView folds in as the target grouping). This seam is the +// data source for that axis: it groups events by targetId. §12 sets the honesty rule — +// conservative attribution, never guess: any event whose target is null/undefined/'' +// goes into ONE explicit "untargeted" bucket (target: null), never orphaned, never +// guessed onto some other target. That bucket always sorts LAST so it reads as the +// residual pile, not as another real target. + +const E = (id: string, timestamp: number, targetId?: string | null): EventLike => ({ + id, + timestamp, + targetId +}) + +describe('groupByTarget', () => { + it('empty input → []', () => { + expect(groupByTarget([])).toEqual([]) + }) + + it('all events share one target → a single group', () => { + const result = groupByTarget([E('a', 10, 'host1'), E('b', 20, 'host1')]) + expect(result).toEqual([ + { target: 'host1', eventIds: ['a', 'b'], firstTs: 10, lastTs: 20 } + ]) + }) + + it('all events targeted → no untargeted group appears', () => { + const result = groupByTarget([E('a', 10, 'host1'), E('b', 20, 'host2')]) + expect(result.some((g) => g.target === null)).toBe(false) + }) + + it('all events untargeted → exactly one target:null group', () => { + const result = groupByTarget([E('a', 10), E('b', 20, null), E('c', 30, '')]) + expect(result).toEqual([ + { target: null, eventIds: ['a', 'b', 'c'], firstTs: 10, lastTs: 30 } + ]) + }) + + it('multiple targets are ordered by first-event ts (ascending)', () => { + // host2 fires first (ts 5), host1 later (ts 10) → host2 must come first. + const result = groupByTarget([E('a', 10, 'host1'), E('b', 5, 'host2'), E('c', 30, 'host1')]) + expect(result.map((g) => g.target)).toEqual(['host2', 'host1']) + }) + + it('untargeted bucket always sorts last, even when its first event is earliest', () => { + // The untargeted event (ts 1) is the earliest of all, yet the bucket must still + // be last: it is the residual pile, never a real target position. + const result = groupByTarget([E('u', 1), E('a', 10, 'host1'), E('b', 20, 'host2')]) + expect(result.map((g) => g.target)).toEqual(['host1', 'host2', null]) + }) + + it('mixes targeted and untargeted events, untargeted collected into the one bucket', () => { + const result = groupByTarget([ + E('a', 10, 'host1'), + E('u1', 15), + E('b', 20, 'host2'), + E('u2', 25, ''), + E('c', 30, 'host1') + ]) + expect(result).toEqual([ + { target: 'host1', eventIds: ['a', 'c'], firstTs: 10, lastTs: 30 }, + { target: 'host2', eventIds: ['b'], firstTs: 20, lastTs: 20 }, + { target: null, eventIds: ['u1', 'u2'], firstTs: 15, lastTs: 25 } + ]) + }) + + it('empty-string target counts as untargeted, not as a distinct target', () => { + const result = groupByTarget([E('a', 10, ''), E('b', 20, 'host1')]) + expect(result.map((g) => g.target)).toEqual(['host1', null]) + expect(result.find((g) => g.target === null)?.eventIds).toEqual(['a']) + }) + + it('unsorted input: both group order and within-group order come out sorted', () => { + const result = groupByTarget([ + E('c', 30, 'host1'), + E('b', 20, 'host2'), + E('a', 10, 'host1') + ]) + // host1 first (firstTs 10) then host2 (firstTs 20); host1's ids in ts order. + expect(result.map((g) => g.target)).toEqual(['host1', 'host2']) + expect(result[0].eventIds).toEqual(['a', 'c']) + }) + + it('same target scattered across time stays one group (not split by gaps)', () => { + const result = groupByTarget([ + E('a', 10, 'host1'), + E('b', 20, 'host2'), + E('c', 100, 'host1') + ]) + const host1 = result.find((g) => g.target === 'host1') + expect(host1?.eventIds).toEqual(['a', 'c']) + expect(host1).toEqual({ target: 'host1', eventIds: ['a', 'c'], firstTs: 10, lastTs: 100 }) + }) + + it('firstTs/lastTs bound the group even for unsorted input', () => { + const result = groupByTarget([E('c', 30, 'h'), E('a', 5, 'h'), E('b', 50, 'h')]) + expect(result[0].firstTs).toBe(5) + expect(result[0].lastTs).toBe(50) + expect(result[0].eventIds).toEqual(['a', 'c', 'b']) + }) +}) diff --git a/test/timeline-axis.test.ts b/test/timeline-axis.test.ts new file mode 100644 index 00000000..5e29a04b --- /dev/null +++ b/test/timeline-axis.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest' +import { lanesForAxis, laneOfEvent, buildLaneModel, UNTARGETED_LANE } from '../src/renderer/src/lib/timelineAxis' +import type { AxisEvent, AxisLane } from '../src/renderer/src/lib/timelineAxis' + +// timelineAxis is the Phase C seam that lets the timeline's swim-lane rows come +// from either the current source-type lanes or a target grouping, WITHOUT the +// renderer caring which. 'source' delegates to the caller's existing lanes so +// the default is a byte-for-byte no-op; 'target' wraps the Phase B groupByTarget +// (untargeted last, §12). laneOfEvent must agree with lanesForAxis on lane ids. + +const SOURCE_LANES: AxisLane[] = [ + { id: 'shell', label: 'Shell' }, + { id: 'dns', label: 'DNS' } +] +const sourceLaneOf = (e: AxisEvent): string => (e as { agentType?: string }).agentType ?? 'system' + +const ev = (id: string, timestamp: number, targetId?: string | null, agentType?: string): AxisEvent => + ({ id, timestamp, targetId, ...(agentType ? { agentType } : {}) } as AxisEvent) + +describe('lanesForAxis — source axis (the zero-change default)', () => { + it('returns the caller-supplied source lanes unchanged', () => { + const lanes = lanesForAxis('source', [ev('a', 1, 'x')], SOURCE_LANES, 'Untargeted') + expect(lanes).toEqual(SOURCE_LANES) + }) + + it('does not consult events for the source axis', () => { + // Even with target data present, source axis ignores it. + const lanes = lanesForAxis('source', [], SOURCE_LANES, 'Untargeted') + expect(lanes).toEqual(SOURCE_LANES) + }) +}) + +describe('lanesForAxis — target axis', () => { + it('builds one lane per target, ordered by first activity, untargeted last', () => { + const events = [ + ev('a', 30, 'b.com'), + ev('b', 10, 'a.com'), + ev('c', 20, null), + ev('d', 40, 'a.com') + ] + const lanes = lanesForAxis('target', events, SOURCE_LANES, 'Untargeted') + // a.com first (ts 10), b.com next (ts 30), untargeted last regardless of ts + expect(lanes.map((l) => l.id)).toEqual(['a.com', 'b.com', UNTARGETED_LANE]) + expect(lanes.map((l) => l.label)).toEqual(['a.com', 'b.com', 'Untargeted']) + }) + + it('uses the untargeted lane label for the null-target bucket only', () => { + const lanes = lanesForAxis('target', [ev('a', 1, null)], SOURCE_LANES, 'No target') + expect(lanes).toEqual([{ id: UNTARGETED_LANE, label: 'No target' }]) + }) + + it('is empty for no events', () => { + expect(lanesForAxis('target', [], SOURCE_LANES, 'Untargeted')).toEqual([]) + }) +}) + +describe('laneOfEvent', () => { + it('uses the source lane function on the source axis', () => { + expect(laneOfEvent('source', ev('a', 1, 'x', 'scanner'), sourceLaneOf)).toBe('scanner') + }) + + it('maps a targeted event to its target id on the target axis', () => { + expect(laneOfEvent('target', ev('a', 1, 'a.com'), sourceLaneOf)).toBe('a.com') + }) + + it('maps null / undefined / empty target to the untargeted lane', () => { + expect(laneOfEvent('target', ev('a', 1, null), sourceLaneOf)).toBe(UNTARGETED_LANE) + expect(laneOfEvent('target', ev('b', 1, undefined), sourceLaneOf)).toBe(UNTARGETED_LANE) + expect(laneOfEvent('target', ev('c', 1, ''), sourceLaneOf)).toBe(UNTARGETED_LANE) + }) + + it('agrees with lanesForAxis: every event lands in a real lane (target axis)', () => { + const events = [ev('a', 1, 'a.com'), ev('b', 2, null), ev('c', 3, 'b.com')] + const laneIds = new Set(lanesForAxis('target', events, SOURCE_LANES, 'U').map((l) => l.id)) + for (const e of events) expect(laneIds.has(laneOfEvent('target', e, sourceLaneOf))).toBe(true) + }) +}) + +describe('buildLaneModel', () => { + it('source: lanes are the source lanes, grouped by sourceLaneOf, every lane seeded', () => { + const events = [ev('a', 1, null, 'shell'), ev('b', 2, null, 'dns'), ev('c', 3, null, 'shell')] + const m = buildLaneModel('source', events, SOURCE_LANES, sourceLaneOf, 'Untargeted') + expect(m.lanes).toEqual(SOURCE_LANES) + expect(m.laneEvents['shell'].map((e) => e.id)).toEqual(['a', 'c']) + expect(m.laneEvents['dns'].map((e) => e.id)).toEqual(['b']) + expect(m.laneOf(events[0])).toBe('shell') + }) + + it('target: lanes from grouping, events bucketed by target, untargeted seeded', () => { + const events = [ev('a', 10, 'x.com'), ev('b', 20, null), ev('c', 30, 'x.com')] + const m = buildLaneModel('target', events, SOURCE_LANES, sourceLaneOf, 'Untargeted') + expect(m.lanes.map((l) => l.id)).toEqual(['x.com', UNTARGETED_LANE]) + expect(m.laneEvents['x.com'].map((e) => e.id)).toEqual(['a', 'c']) + expect(m.laneEvents[UNTARGETED_LANE].map((e) => e.id)).toEqual(['b']) + }) + + it('every lane in `lanes` has an entry even when empty (source seeding)', () => { + const m = buildLaneModel('source', [ev('a', 1, null, 'shell')], SOURCE_LANES, sourceLaneOf, 'U') + expect(m.laneEvents['dns']).toEqual([]) // seeded empty, safe to index + }) +}) diff --git a/test/timeline-cluster.test.ts b/test/timeline-cluster.test.ts new file mode 100644 index 00000000..a9f193ea --- /dev/null +++ b/test/timeline-cluster.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest' +import { clusterEvents } from '../src/renderer/src/lib/timelineCluster' +import type { ClusterInput } from '../src/renderer/src/lib/timelineCluster' + +// Pins the behaviour of Timeline.tsx's inline `clusters` memo (the 14px same-lane +// bucketing) before the axis refactor (SPEC-TIMELINE-AXIS Step 1). These are +// characterisation tests: any drift here is a change to what the operator sees on +// the track, so they mirror the inline grid-bucketing semantics exactly — +// consecutive same-bin merge, mean-x representative, first-id key, input order. + +const ev = (id: string, lane: string, x: number): ClusterInput => ({ id, lane, x }) + +describe('clusterEvents', () => { + it('returns [] for empty input', () => { + expect(clusterEvents([])).toEqual([]) + }) + + it('makes a single-element cluster for one event', () => { + expect(clusterEvents([ev('a', 'shell', 5)])).toEqual([ + { key: 'shell-a', lane: 'shell', x: 5, ids: ['a'], count: 1 } + ]) + }) + + it('merges same-lane events inside one 14px bin into one counted cluster', () => { + // x=2 and x=10 → bin 0 for both (Math.floor(x/14) === 0). + const out = clusterEvents([ev('a', 'shell', 2), ev('b', 'shell', 10)]) + expect(out).toHaveLength(1) + expect(out[0]).toEqual({ + key: 'shell-a', + lane: 'shell', + x: 6, // mean of 2 and 10 + ids: ['a', 'b'], + count: 2 + }) + }) + + it('does not merge events on different lanes even at the same x', () => { + const out = clusterEvents([ev('a', 'shell', 3), ev('b', 'http', 3)]) + expect(out).toHaveLength(2) + expect(out.map((c) => c.lane)).toEqual(['shell', 'http']) + expect(out.every((c) => c.count === 1)).toBe(true) + }) + + it('splits events into separate clusters once they cross a bin boundary', () => { + // x=13 → bin 0, x=15 → bin 1: only 2px apart but on opposite sides of the + // 14px grid line, so the inline code keeps them separate. Mirrored here. + const out = clusterEvents([ev('a', 'shell', 13), ev('b', 'shell', 15)]) + expect(out).toHaveLength(2) + expect(out.map((c) => c.ids)).toEqual([['a'], ['b']]) + }) + + it('treats an exact multiple of mergePx as the start of the next bin', () => { + // x=13 → bin 0, x=14 → bin 1 (14/14 === 1). Boundary is exclusive-below. + const out = clusterEvents([ev('a', 'shell', 13), ev('b', 'shell', 14)]) + expect(out).toHaveLength(2) + }) + + it('counts a dense burst and averages the member xs', () => { + // Four events all inside bin 0 (0..13). + const out = clusterEvents([ + ev('a', 'shell', 0), + ev('b', 'shell', 4), + ev('c', 'shell', 8), + ev('d', 'shell', 12) + ]) + expect(out).toHaveLength(1) + expect(out[0].count).toBe(4) + expect(out[0].ids).toEqual(['a', 'b', 'c', 'd']) + expect(out[0].x).toBe(6) // (0+4+8+12)/4 + expect(out[0].key).toBe('shell-a') // key uses the first member id + }) + + it('emits clusters per lane in first-seen lane order, events in input order', () => { + // Two lanes interleaved in the input; grouping is stable by first appearance. + const out = clusterEvents([ + ev('a', 'http', 2), + ev('b', 'shell', 2), + ev('c', 'http', 6), + ev('d', 'shell', 40) + ]) + // http seen first → its clusters come first. a+c share bin 0 → one cluster. + // shell: b in bin 0, d in bin 2 → two clusters. + expect(out.map((c) => c.lane)).toEqual(['http', 'shell', 'shell']) + expect(out[0]).toMatchObject({ lane: 'http', ids: ['a', 'c'], count: 2 }) + expect(out[1]).toMatchObject({ lane: 'shell', ids: ['b'], count: 1 }) + expect(out[2]).toMatchObject({ lane: 'shell', ids: ['d'], count: 1 }) + }) + + it('honours a custom mergePx threshold', () => { + // With mergePx=100, x=2 and x=90 fall in the same bin 0 and merge, whereas + // the default 14 would split them (bins 0 and 6). + expect(clusterEvents([ev('a', 'shell', 2), ev('b', 'shell', 90)], 100)).toHaveLength(1) + expect(clusterEvents([ev('a', 'shell', 2), ev('b', 'shell', 90)])).toHaveLength(2) + }) +}) diff --git a/test/timeline-modes.test.ts b/test/timeline-modes.test.ts new file mode 100644 index 00000000..ffd9d1f8 --- /dev/null +++ b/test/timeline-modes.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from 'vitest' +import { activeModes } from '../src/renderer/src/lib/timelineModes' +import type { TimelineModeState } from '../src/renderer/src/lib/timelineModes' + +// The Timeline carries several sticky, non-default modes that each HIDE or +// TRANSFORM events (focus chain, anomaly filter, agent-turn collapse, lane solo, +// hidden lanes, text filter). Left on, they turn the track near-empty and the +// operator — back from a break — has no idea WHY. activeModes derives the +// "Active:" row (DESIGN-TIMELINE-INTERACTION §T3): one dismissible chip per +// non-default mode, in a fixed order, and NOTHING when everything is default so +// the UI can drop the row entirely. Keeping it pure means the exact chip set is +// written down and testable, not tangled into the header render. + +const S = (over: Partial = {}): TimelineModeState => ({ + focusActive: false, + anomalyOnly: false, + collapseAgentTurns: false, + soloLane: null, + hiddenLaneCount: 0, + filterQuery: '', + ...over +}) + +describe('activeModes', () => { + it('returns [] when every mode is default (no row is drawn)', () => { + expect(activeModes(S())).toEqual([]) + }) + + it('emits a focus chip when the focus chain is on', () => { + const chips = activeModes(S({ focusActive: true })) + expect(chips).toHaveLength(1) + expect(chips[0].id).toBe('focus') + expect(chips[0].labelKey).toBe('timeline.mode.focus') + expect(chips[0].clearAction).toBe('clear-focus') + }) + + it('emits an anomaly chip when the anomaly filter is on', () => { + const chips = activeModes(S({ anomalyOnly: true })) + expect(chips.map((c) => c.id)).toEqual(['anomaly']) + expect(chips[0].labelKey).toBe('timeline.mode.anomaly') + expect(chips[0].clearAction).toBe('clear-anomaly') + }) + + it('emits a collapse-agent chip when agent turns are collapsed', () => { + const chips = activeModes(S({ collapseAgentTurns: true })) + expect(chips.map((c) => c.id)).toEqual(['collapse-agent']) + expect(chips[0].labelKey).toBe('timeline.mode.collapseAgent') + expect(chips[0].clearAction).toBe('clear-collapse-agent') + }) + + it('emits a solo chip carrying the lane name when a lane is soloed', () => { + const chips = activeModes(S({ soloLane: 'shell' })) + expect(chips.map((c) => c.id)).toEqual(['solo']) + expect(chips[0].value).toBe('shell') + expect(chips[0].labelKey).toBe('timeline.mode.solo') + expect(chips[0].clearAction).toBe('clear-solo') + }) + + it('emits a hidden-lanes chip carrying the count when lanes are hidden', () => { + const chips = activeModes(S({ hiddenLaneCount: 4 })) + expect(chips.map((c) => c.id)).toEqual(['hidden-lanes']) + expect(chips[0].value).toBe('4') + expect(chips[0].labelKey).toBe('timeline.mode.hiddenLanes') + expect(chips[0].clearAction).toBe('clear-hidden-lanes') + }) + + it('emits a filter chip carrying the query when the text filter is set', () => { + const chips = activeModes(S({ filterQuery: 'GET /admin' })) + expect(chips.map((c) => c.id)).toEqual(['filter']) + expect(chips[0].value).toBe('GET /admin') + expect(chips[0].labelKey).toBe('timeline.mode.filter') + expect(chips[0].clearAction).toBe('clear-filter') + }) + + it('solo and hidden-lanes are mutually exclusive — solo wins when both apply', () => { + // A soloed lane logically hides every other lane, so the hiddenLaneCount is + // redundant noise while solo is on; the chip row shows the sharper fact. + const chips = activeModes(S({ soloLane: 'http', hiddenLaneCount: 12 })) + expect(chips.map((c) => c.id)).toEqual(['solo']) + expect(chips[0].value).toBe('http') + }) + + it('falls back to hidden-lanes only when no lane is soloed', () => { + const chips = activeModes(S({ soloLane: null, hiddenLaneCount: 3 })) + expect(chips.map((c) => c.id)).toEqual(['hidden-lanes']) + }) + + it('follow mode is never a chip — it does not hide data (open-question resolution)', () => { + // DESIGN-TIMELINE-INTERACTION open question #3: "following" is auto-scroll, + // not a mode that hides events, so it stays a header toggle. There is no + // follow field on TimelineModeState precisely to keep it out of this seam. + expect(activeModes(S())).toEqual([]) + }) + + it('holds a fixed order: focus → anomaly → collapse-agent → solo → hidden → filter', () => { + const chips = activeModes( + S({ + focusActive: true, + anomalyOnly: true, + collapseAgentTurns: true, + soloLane: 'agent', + hiddenLaneCount: 9, // suppressed by solo + filterQuery: 'sqlmap' + }) + ) + expect(chips.map((c) => c.id)).toEqual([ + 'focus', + 'anomaly', + 'collapse-agent', + 'solo', + 'filter' + ]) + }) + + it('preserves that order when solo is absent so hidden-lanes takes the slot', () => { + const chips = activeModes( + S({ focusActive: true, hiddenLaneCount: 2, filterQuery: 'x' }) + ) + expect(chips.map((c) => c.id)).toEqual(['focus', 'hidden-lanes', 'filter']) + }) + + it('ignores a whitespace-free empty filter but keeps a real query verbatim', () => { + expect(activeModes(S({ filterQuery: '' }))).toEqual([]) + const chips = activeModes(S({ filterQuery: ' ' })) + // A query of only spaces still filters nothing meaningfully, but the seam + // treats emptiness as strictly '' — spaces are a real (if odd) query. + expect(chips.map((c) => c.id)).toEqual(['filter']) + expect(chips[0].value).toBe(' ') + }) +}) diff --git a/test/timeline-wheel.test.ts b/test/timeline-wheel.test.ts new file mode 100644 index 00000000..d2bb60d1 --- /dev/null +++ b/test/timeline-wheel.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest' +import { wheelMode, type WheelContext } from '../src/renderer/src/lib/timelineWheel' + +// Helper: build a WheelContext with sane defaults, override what a case cares about. +const ctx = (over: Partial = {}): WheelContext => ({ + overflow: false, + shiftKey: false, + zoomKey: false, + ...over +}) + +describe('wheelMode — the 4-row decision matrix (DESIGN-TIMELINE-INTERACTION T2)', () => { + // Row 1: zoomKey=T, shift=—, overflow=— → 'zoom' + it('zoomKey → zoom (overflow=false, shift=false)', () => { + expect(wheelMode(ctx({ zoomKey: true, overflow: false, shiftKey: false }))).toBe('zoom') + }) + + // Row 2: zoomKey=F, shift=—, overflow=F → 'pan-x' (common case) + it('no zoomKey, no overflow → pan-x (common case)', () => { + expect(wheelMode(ctx({ zoomKey: false, overflow: false, shiftKey: false }))).toBe('pan-x') + }) + + // Row 3: zoomKey=F, shift=T, overflow=T → 'pan-x' (shift overrides scroll) + it('no zoomKey, shift held, overflow → pan-x (shift overrides scroll-y)', () => { + expect(wheelMode(ctx({ zoomKey: false, shiftKey: true, overflow: true }))).toBe('pan-x') + }) + + // Row 4: zoomKey=F, shift=F, overflow=T → 'scroll-y' (the surprising row) + it('no zoomKey, no shift, overflow → scroll-y (the surprising row)', () => { + expect(wheelMode(ctx({ zoomKey: false, shiftKey: false, overflow: true }))).toBe('scroll-y') + }) +}) + +describe('wheelMode — zoom precedence (zoom wins over shift & overflow)', () => { + it('zoomKey beats shift', () => { + expect(wheelMode(ctx({ zoomKey: true, shiftKey: true }))).toBe('zoom') + }) + + it('zoomKey beats overflow', () => { + expect(wheelMode(ctx({ zoomKey: true, overflow: true }))).toBe('zoom') + }) + + it('zoomKey beats shift AND overflow together', () => { + expect(wheelMode(ctx({ zoomKey: true, shiftKey: true, overflow: true }))).toBe('zoom') + }) +}) + +describe('wheelMode — shift only matters while overflow is true', () => { + it('shift with no overflow is still the common pan-x path', () => { + expect(wheelMode(ctx({ zoomKey: false, shiftKey: true, overflow: false }))).toBe('pan-x') + }) +}) diff --git a/test/vpn-adapters.test.ts b/test/vpn-adapters.test.ts new file mode 100644 index 00000000..a19e42d3 --- /dev/null +++ b/test/vpn-adapters.test.ts @@ -0,0 +1,139 @@ +// network.vpnAdapters — the operator-editable list of interface-name patterns +// that decides which links count as "the tunnel is up". +// +// It is the only config field whose values are user-supplied REGEXES, so both +// halves matter: an enabled pattern has to match, and a broken pattern must not +// take the OPSEC poller down with it. + +import { describe, it, expect, beforeEach, vi } from 'vitest' +import type { VpnAdapter } from '../src/core/config' + +const ifaceMock = vi.fn() + +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, default: actual, networkInterfaces: () => ifaceMock(), hostname: () => 'test-host' } +}) + +// readOpsecState also probes DNS through a shell-out; answer it with nothing. +vi.mock('child_process', () => ({ + exec: (_cmd: string, _opts: unknown, cb: (e: Error | null, out: string) => void) => cb(null, '') +})) + +const { setVpnAdapters, readOpsecState } = await import('../src/main/services/opsec-state') +const { DEFAULT_VPN_ADAPTERS } = await import('../src/core/config') + +/** One external IPv4 address on each named interface. */ +function links(...names: string[]): Record { + const out: Record = { + lo0: [{ address: '127.0.0.1', family: 'IPv4', internal: true, mac: '00:00:00:00:00:00' }] + } + names.forEach((n, i) => { + out[n] = [{ address: `10.9.${i}.2`, family: 'IPv4', internal: false, mac: `aa:bb:cc:00:00:0${i}` }] + }) + return out +} + +const adapter = (name: string, pattern: string, enabled = true): VpnAdapter => ({ name, pattern, enabled }) + +async function vpnFor(adapters: VpnAdapter[], ifaces: Record): Promise { + setVpnAdapters(adapters) + ifaceMock.mockReturnValue(ifaces) + return (await readOpsecState()).vpnInterfaces +} + +beforeEach(() => ifaceMock.mockReset()) + +describe('vpnAdapters — pattern matching', () => { + it('an enabled pattern marks the matching interface as VPN', async () => { + expect(await vpnFor([adapter('WireGuard', 'wireguard|^wg\\d')], links('wg0', 'en0'))).toEqual(['wg0']) + }) + + it('patterns are case-insensitive', async () => { + expect(await vpnFor([adapter('Tailscale', 'tailscale')], links('Tailscale0'))).toEqual(['Tailscale0']) + }) + + it('a disabled adapter matches nothing', async () => { + expect(await vpnFor([adapter('WireGuard', '^wg\\d', false)], links('wg0'))).toEqual([]) + }) + + it('an empty adapter list means no interface is ever VPN', async () => { + expect(await vpnFor([], links('wg0', 'utun3'))).toEqual([]) + }) + + it('several adapters can match at once, and the result is sorted', async () => { + const out = await vpnFor( + [adapter('WireGuard', '^wg\\d'), adapter('macOS utun', '^utun')], + links('utun3', 'wg0', 'en0') + ) + expect(out).toEqual(['utun3', 'wg0']) + }) + + it('an interface with no external address is not counted, even if the name matches', async () => { + ifaceMock.mockReturnValue({ wg0: [{ address: '127.0.0.1', family: 'IPv4', internal: true, mac: 'x' }] }) + setVpnAdapters([adapter('WireGuard', '^wg\\d')]) + expect((await readOpsecState()).vpnInterfaces).toEqual([]) + }) + + it('an interface with an empty address list is skipped', async () => { + ifaceMock.mockReturnValue({ wg0: [] }) + setVpnAdapters([adapter('WireGuard', '^wg\\d')]) + expect((await readOpsecState()).vpnInterfaces).toEqual([]) + }) + + it('a malformed pattern is dropped instead of throwing — the poller keeps running', async () => { + const out = await vpnFor( + [adapter('broken', '([unclosed'), adapter('macOS utun', '^utun')], + links('utun3') + ) + expect(out).toEqual(['utun3']) + }) + + it('re-configuring replaces the previous pattern set rather than adding to it', async () => { + expect(await vpnFor([adapter('WireGuard', '^wg\\d')], links('wg0', 'utun3'))).toEqual(['wg0']) + expect(await vpnFor([adapter('macOS utun', '^utun')], links('wg0', 'utun3'))).toEqual(['utun3']) + }) +}) + +describe('the shipped adapter list recognises the common clients', () => { + const NAMES: Array<[string, string]> = [ + ['WireGuard', 'wg0'], + ['OpenVPN', 'tun0'], + ['OpenVPN tap', 'tap0'], + ['Tailscale', 'tailscale0'], + ['NordVPN', 'nordlynx'], + ['ProtonVPN', 'proton0'], + ['macOS utun', 'utun4'], + ['IPSec', 'ipsec0'], + ['PPP', 'ppp0'] + ] + + for (const [client, iface] of NAMES) { + it(`${client} (${iface})`, async () => { + expect(await vpnFor(DEFAULT_VPN_ADAPTERS, links(iface, 'en0'))).toEqual([iface]) + }) + } + + it('does not mistake an ordinary wired/Wi-Fi interface for a tunnel', async () => { + expect(await vpnFor(DEFAULT_VPN_ADAPTERS, links('en0', 'eth0', 'wlan0'))).toEqual([]) + }) +}) + +describe('the rest of the OPSEC snapshot still comes back', () => { + it('reports the primary MAC and hostname alongside the VPN list', async () => { + setVpnAdapters(DEFAULT_VPN_ADAPTERS) + ifaceMock.mockReturnValue(links('en0')) + const state = await readOpsecState() + expect(state.primaryMac).toBe('aa:bb:cc:00:00:00') + expect(state.hostname).toBe('test-host') + }) + + it('skips the all-zero MAC of a virtual/loopback adapter', async () => { + ifaceMock.mockReturnValue({ + lo0: [{ address: '127.0.0.1', family: 'IPv4', internal: true, mac: '00:00:00:00:00:00' }], + en0: [{ address: '192.168.1.5', family: 'IPv4', internal: false, mac: '00:00:00:00:00:00' }] + }) + setVpnAdapters([]) + expect((await readOpsecState()).primaryMac).toBeNull() + }) +}) diff --git a/test/wifi-name-policy.test.ts b/test/wifi-name-policy.test.ts new file mode 100644 index 00000000..c6e0cfbb --- /dev/null +++ b/test/wifi-name-policy.test.ts @@ -0,0 +1,59 @@ +// network.showWifiName (G-NET1). +// +// The setting shipped honoured by nothing: Settings wrote it, `detectLink()` +// always probed, and the SSID was always displayed once the OS permission +// existed — whichever way the operator set the toggle. That matters more than a +// cosmetic default, because the SSID names the building the operator is sitting +// in and it rides along on every `ip:status` into the HUD, which is the surface +// guaranteed to be in frame on a screenshot or a screen-share. + +import { describe, it, expect } from 'vitest' +import { applyWifiNamePolicy } from '../src/main/services/network-info' +import type { NetworkLink } from '../src/main/services/network-info' + +const wifi = (name: string): NetworkLink => ({ type: 'wifi', name }) + +describe('applyWifiNamePolicy', () => { + it('off (the default): the SSID is dropped', () => { + expect(applyWifiNamePolicy(wifi('TARGETCORP-GUEST'), false)).toEqual({ type: 'wifi', name: '' }) + }) + + it('off: the link TYPE survives — "on Wi-Fi" is a fact worth showing', () => { + // The UI renders a generic "Wi-Fi" from the type when the name is blank, so + // the operator still sees wireless-vs-wired without naming the place. + expect(applyWifiNamePolicy(wifi('Cafe_Free'), false).type).toBe('wifi') + }) + + it('on: the SSID is shown', () => { + expect(applyWifiNamePolicy(wifi('TARGETCORP-GUEST'), true)).toEqual({ type: 'wifi', name: 'TARGETCORP-GUEST' }) + }) + + const untouched: Array<[string, NetworkLink]> = [ + ['wired', { type: 'wired', name: '' }], + ['unknown', { type: 'unknown', name: '' }], + ['wifi with no name (OS withheld it)', { type: 'wifi', name: '' }] + ] + for (const [name, link] of untouched) { + it(`leaves a ${name} link alone either way`, () => { + expect(applyWifiNamePolicy(link, false)).toEqual(link) + expect(applyWifiNamePolicy(link, true)).toEqual(link) + }) + } + + it('does not mutate the link it was handed', () => { + const original = wifi('KEEPME') + applyWifiNamePolicy(original, false) + expect(original.name).toBe('KEEPME') + }) + + it('is idempotent — re-applying an already-redacted link changes nothing', () => { + const once = applyWifiNamePolicy(wifi('SSID'), false) + expect(applyWifiNamePolicy(once, false)).toEqual(once) + }) + + it('an SSID that looks like a placeholder is still dropped when off', () => { + // detectMac already rejects "" / "not associated" and yields an + // empty name; anything that does get through is still gated here. + expect(applyWifiNamePolicy(wifi('unknown ssid'), false).name).toBe('') + }) +}) diff --git a/tools/redlog-verify.py b/tools/redlog-verify.py index 242cd641..0ccc1fc4 100644 --- a/tools/redlog-verify.py +++ b/tools/redlog-verify.py @@ -252,6 +252,51 @@ def _load_operators(bundle_dir: Path) -> Dict[str, Optional[str]]: return {} +def _extract_io_refs(row: Dict[str, Any]) -> List[str]: + """io_ref sidecar digests (SPEC-IO-SIDECAR.md) carried by an event, if any. + The full HTTP bodies live in the bundle's io/ dir as .bin; the event + holds only the digest, so verify re-hashes the file against it (A4).""" + data_field = row.get("data") + if isinstance(data_field, str): + try: + data_field = json.loads(data_field) + except Exception: + return [] + if not isinstance(data_field, dict): + return [] + io = data_field.get("io") + if not isinstance(io, dict): + return [] + refs: List[str] = [] + for slot in ("request", "response"): + ref = io.get(slot) + if isinstance(ref, dict) and isinstance(ref.get("ref"), str): + refs.append(ref["ref"]) + return refs + + +def _extract_io_swaps(row: Dict[str, Any]) -> Dict[str, str]: + """Scope-sanitize io digest swaps (SPEC-SCOPE-AWARE-LIFECYCLE.md Part B) from + a system.sanitized event: {orig_sha: replacement_sha}. The bundle serves the + redacted replacement under the ORIGINAL name, so verify must expect the + replacement digest for those bodies — a match is *sanitized*, not tampered.""" + if row.get("agent_type") != "system": + return {} + data_field = row.get("data") + if isinstance(data_field, str): + try: + data_field = json.loads(data_field) + except Exception: + return {} + if not isinstance(data_field, dict) or data_field.get("subtype") != "sanitized": + return {} + swaps: Dict[str, str] = {} + for r in (data_field.get("io_replacements") or []): + if isinstance(r, dict) and isinstance(r.get("ref"), str) and isinstance(r.get("sha256"), str): + swaps[r["ref"]] = r["sha256"] + return swaps + + def _iter_events(events_path: Path): with events_path.open("r", encoding="utf-8") as fh: for lineno, line in enumerate(fh, start=1): @@ -302,10 +347,16 @@ def verify_bundle(bundle_dir: Path, verbose: bool = False) -> int: bad_sig_at: Optional[str] = None last_hash: Optional[str] = None bundle_engagement = manifest.get("engagementId") + io_refs: Dict[str, str] = {} # ref -> first event id that carries it + io_swaps: Dict[str, str] = {} # orig sha -> replacement sha (scope sanitize) for lineno, row in _iter_events(events_path): walked += 1 rid = row.get("id", f"") + for ref in _extract_io_refs(row): + io_refs.setdefault(ref, str(rid)) + for orig, repl in _extract_io_swaps(row).items(): + io_swaps[orig] = repl row_prev = row.get("prev_hash") if row_prev is not None: @@ -403,6 +454,64 @@ def verify_bundle(bundle_dir: Path, verbose: bool = False) -> int: ).hexdigest() head_ok = recomputed == manifest_head_hash + # io_ref sidecar bodies (SPEC-IO-SIDECAR.md A4). The chain attests each + # body's sha256; the bytes live in io/.bin. Re-hash every referenced + # file: a match confirms the bytes are the ones attested; a MISSING file is + # reported as *pruned* (retention removed it) not tampered; a mismatch is + # tampering and fails the bundle. + io_ok = 0 + io_pruned = 0 + io_sanitized = 0 + io_bad_at: Optional[str] = None + io_dir = bundle_dir / "io" + for ref, ev_id in io_refs.items(): + raw = io_dir / f"{ref}.bin" + gz = io_dir / f"{ref}.bin.gz" + # Warm (compressed) bodies keep the ORIGINAL sha256 as their stem, so we + # decompress before hashing (SPEC-SCOPE-AWARE-LIFECYCLE.md A4). A missing + # file (neither raw nor warm) is pruned, not tampered. + if raw.exists(): + f, compressed = raw, False + elif gz.exists(): + f, compressed = gz, True + else: + io_pruned += 1 + continue + try: + data = f.read_bytes() + if compressed: + import gzip as _gzip + data = _gzip.decompress(data) + actual = hashlib.sha256(data).hexdigest() + except Exception: + io_pruned += 1 + continue + # Scope-sanitized body: the bundle serves the redacted replacement under + # the original name, so its bytes must hash to the RECORDED replacement + # digest. Match = sanitized (expected), mismatch = tampered. + if ref in io_swaps: + if actual == io_swaps[ref]: + io_sanitized += 1 + else: + io_bad_at = ev_id + print( + f"IO SIDECAR TAMPERED: sanitized io/{ref}.bin does not match " + f"its recorded replacement digest (event {ev_id}); computed {actual[:16]}...", + file=sys.stderr, + ) + break + continue + if actual == ref: + io_ok += 1 + else: + io_bad_at = ev_id + print( + f"IO SIDECAR TAMPERED: io/{ref}.bin does not match its chained " + f"digest (event {ev_id}); computed {actual[:16]}...", + file=sys.stderr, + ) + break + # --------------------------------------------------------------------- # Report # --------------------------------------------------------------------- @@ -432,9 +541,20 @@ def verify_bundle(bundle_dir: Path, verbose: bool = False) -> int: print(f"Unsigned events : {unsigned}") if bad_sig_at: print(f"BAD SIGNATURE : {bad_sig_at}") + if io_refs: + summary = f"{io_ok} verified" + if io_sanitized: + summary += f", {io_sanitized} sanitized (scope — not tampered)" + if io_pruned: + summary += f", {io_pruned} pruned (retention — not tampered)" + print(f"IO sidecars : {summary}") + if io_bad_at: + print(f"IO SIDECAR BAD : {io_bad_at}") if head_ok is False: return 1 + if io_bad_at: + return 1 return 0 diff --git a/tsconfig.web.json b/tsconfig.web.json index 3450694e..c47845d0 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -1,15 +1,17 @@ { "compilerOptions": { "composite": true, + "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "jsx": "react-jsx", "allowSyntheticDefaultImports": true, "esModuleInterop": true, + "resolveJsonModule": true, "strict": true, "skipLibCheck": true, "outDir": "./out", "lib": ["ESNext", "DOM", "DOM.Iterable"] }, - "include": ["src/renderer/src/**/*"] + "include": ["src/renderer/src/**/*", "src/renderer/src/**/*.json"] }