From da7825463e07860efce386f2bff06788dc4fd901 Mon Sep 17 00:00:00 2001 From: eric-sabe Date: Tue, 7 Jul 2026 17:35:40 -0400 Subject: [PATCH 1/2] feat(baseline): pin-and-diff suppression baseline (bash + PowerShell) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit honey's verdict is worst-wins across bumblebee + every lens, and a lens flips to `exposed` on the first finding at any severity. A daily run over trusted, first-party agent skills emits 100+ static-analysis false positives (reference docs, LICENSE files, MCP examples) that drag OVERALL to EXPOSED every day and bury real findings. This adds a suppression baseline that lets you acknowledge a reviewed-benign finding ONCE — but as a PIN, not a mute. Each entry records a sha256 of the referenced file, so: • pinned + file unchanged -> suppressed (dropped from the verdict, still counted) • pinned + file changed -> MUTATED, resurfaces loudly (the rug-pull tripwire) • pin past its expiry -> resurfaces as a normal finding Suppression is a report-layer re-rank, never a deletion; a suppressed run never reads as a bare all-clear (the verdict always carries the tallies, e.g. `OVERALL: CLEAN (12 suppressed)`). Design (docs/BASELINE.plan.md) follows adjacent ecosystems: content hash over gitleaks' location-only fingerprint, ~-relative paths (portable), Semgrep-style occurrence index + deterministic ordering, expiry, and bumblebee opt-in guard. The baseline file is committed and reviewable — that auditability is itself an anti-gaming control. Multi-OS parity preserved: mirrored in the win/ PowerShell port. bash: lib/baseline.sh, honey-baseline.sh; report.sh, daily-cycle.sh, doctor.sh integrate it; honey.baseline.json starter. win: win/lib/Baseline.psm1, win/honey-baseline.ps1; report/daily-cycle/ doctor updated. Verified behavior-identical on macOS bash 3.2 + pwsh 7.5.4 (incl. byte-identical file hashes); shellcheck clean; PSScriptAnalyzer clean; report.sh output is byte-identical to before when the baseline is empty (no regression). Docs updated: README (new section + config + layout), routine-prompt.md and triage-guide.md (teach the CLEAN(N suppressed)/MUTATED semantics), win/TESTING.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 4 + README.md | 64 +++++++++- daily-cycle.sh | 24 +++- docs/BASELINE.plan.md | 219 ++++++++++++++++++++++++++++++++ doctor.sh | 17 +++ honey-baseline.sh | 240 +++++++++++++++++++++++++++++++++++ honey.baseline.json | 5 + lib/baseline.sh | 254 +++++++++++++++++++++++++++++++++++++ report.sh | 151 ++++++++++++++++------ routine-prompt.md | 14 +++ triage-guide.md | 8 ++ win/TESTING.md | 32 ++++- win/daily-cycle.ps1 | 21 +++- win/doctor.ps1 | 15 +++ win/honey-baseline.ps1 | 206 ++++++++++++++++++++++++++++++ win/lib/Baseline.psm1 | 277 +++++++++++++++++++++++++++++++++++++++++ win/report.ps1 | 93 ++++++++++---- 17 files changed, 1569 insertions(+), 75 deletions(-) create mode 100644 docs/BASELINE.plan.md create mode 100755 honey-baseline.sh create mode 100644 honey.baseline.json create mode 100755 lib/baseline.sh create mode 100644 win/honey-baseline.ps1 create mode 100644 win/lib/Baseline.psm1 diff --git a/.gitignore b/.gitignore index 9bd0f72..718db26 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,10 @@ cycle.log honey.conf honey.conf.ps1 +# NOTE: honey.baseline.json is intentionally NOT ignored. Suppression pins are +# committed and reviewed in git — that auditability is an anti-gaming control +# (see docs/BASELINE.plan.md). Do not add it here. + # Local secrets, if you ever add any (e.g. Slack webhook/token). secrets.env *.env diff --git a/README.md b/README.md index 5229e26..c2b6b1e 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,60 @@ and honey treats them differently: honey never mutates Python environments. Re-install it periodically per its README to get new patterns. +## Suppression baseline (pin-and-diff) + +Static scanners are noisy. A daily run over trusted, first-party agent skills +can emit dozens of findings that are false positives against reference or +documentation material — enough to drag `OVERALL` to `EXPOSED` every day and +bury a *real* finding. honey lets you acknowledge a reviewed-benign finding +**once** so it stops contributing to the daily verdict — without going blind to +the thing honey exists to catch. + +The mechanism is **pin, not mute**. A suppression entry doesn't say "ignore this +skill" (trust-by-location is blindness-by-location — a first-party plugin that +ships a malicious update keeps its path). It says *"I reviewed this exact content +and judged this finding benign — tell me the instant the content changes."* Each +entry records a **sha256 of the referenced file**, so: + +- pinned finding, file **unchanged** → **suppressed** (dropped from the verdict, still counted); +- pinned finding, file **changed** → **MUTATED** — resurfaces loudly (the rug-pull tripwire); +- pin past its **expiry** (default 90 days) → resurfaces as normal. + +Every suppressed finding is therefore also a change-detector on the content it +vouches for. Suppression is a **report-layer re-rank, never a deletion** — raw +run records are untouched — and a suppressed run **never** reads as a bare +all-clear: the verdict always carries the tallies, e.g. +`OVERALL: CLEAN (12 suppressed)` or `OVERALL: EXPOSED — … (12 suppressed, 2 mutated)`. +Design details and the threat model are in [`docs/BASELINE.plan.md`](docs/BASELINE.plan.md). + +**Manage it** with [`honey-baseline.sh`](honey-baseline.sh) (Windows: +[`win/honey-baseline.ps1`](win/honey-baseline.ps1)) — it only edits +`honey.baseline.json`, never your system: + +```bash +./honey-baseline.sh status # dry-run: suppressed/mutated/expired/active tally +./honey-baseline.sh add --scanner skillspector \ + --location references/ \ + --reason "first-party marketplace docs; describes patterns, not executable" --expires 90 +./honey-baseline.sh list [--expired|--active] # review pins (flags expired) +./honey-baseline.sh remove --scanner skillspector --location references/ +./honey-baseline.sh prune # drop expired pins +``` + +`add` filters are combinable (AND): `--all` (excludes bumblebee), `--scanner`, +`--severity`, `--rule`, `--location`. Pinning a **bumblebee** (known-compromised) +match is opt-in and loud — `--all` excludes it; you must name `--scanner +bumblebee` explicitly. `honey.baseline.json` is **committed and reviewable** on +purpose: baseline edits show up in `git diff` / PR review, which is itself an +anti-gaming control. + +**What it does and does not defend.** The content hash is the right defense +against silent post-review mutation (manifest-alteration rug pulls, source +swaps). It is structurally blind to payloads that were never flagged in the +first place — sleeper/trigger-activated code, instructions fetched from a remote +URL at runtime, or content hidden in bundled images. The baseline can only +suppress what a lens surfaced; widening the scanned surface is separate work. + ## Optional: daily Slack triage via a Claude Local routine If you use [Claude Code](https://claude.com/claude-code) and have the Slack @@ -293,6 +347,7 @@ one-off run. You can also hand-edit `honey.conf` — one `export VAR=…` per li | `HONEY_OSV_EXCLUDE_PATHS` | `node_modules`/`vendor`/`.pnpm`/`testdata`/`fixtures`/`.claude/worktrees` (ERE) | osv-scanner: skip findings whose source path matches — keeps the scan on *declared* manifests, not installed/vendored copies. Set empty to scan everything. | | `HONEY_OSV_NO_DEDUPE` | `0` | osv-scanner: `1` keeps one finding per path; default collapses identical `package@version`+advisory across paths into one (`+N more`) | | `HONEY_SKILLSPECTOR_LLM` | `0` | skillspector: enable its own LLM stage (default static `--no-llm`) | +| `HONEY_BASELINE` | `/honey.baseline.json` | path to the suppression baseline (pin-and-diff); see [Suppression baseline](#suppression-baseline-pin-and-diff) | ## Layout @@ -305,13 +360,18 @@ honey/ ├── daily-cycle.sh # one cycle: run-scan, exit 0=clean / 1=needs attention ├── notify-cycle.sh # scan + native desktop notification (no-Claude path) ├── report.sh # deterministic triage report (bumblebee + all lenses; no AI) +├── honey-baseline.sh # manage the pin-and-diff suppression baseline (add/list/remove/prune/status) +├── honey.baseline.json # the suppression baseline (committed & reviewable; NOT gitignored) ├── install-schedule.sh # schedule notify-cycle via launchd (macOS) / cron (Linux) +├── lib/baseline.sh # shared suppression classifier (sourced by report/daily-cycle/CLI) ├── lenses/ # optional additional scanners, each self-skips if its tool is absent │ ├── skillspector.sh # AI agent skills (NVIDIA SkillSpector) │ ├── osv-scanner.sh # multi-ecosystem lockfile vulns (Google/OSV) │ └── govulncheck.sh # Go reachability-aware vulns (Go vuln team) +├── docs/BASELINE.plan.md # suppression baseline design + threat model ├── routine-prompt.md # prompt for the Claude Local routine (scheduled path) ├── triage-guide.md # guide for triaging a run by hand in a chat +├── win/ # native Windows (PowerShell 7) port — mirrors every script above ├── runs// # one timestamped run per scan (gitignored — host inventory) │ ├── manifest.json # bumblebee verdict + metadata │ ├── findings.ndjson # bumblebee finding records @@ -320,7 +380,9 @@ honey/ ``` `runs/`, `latest`, and `*.log` are **gitignored** — they contain an inventory -of your machine and should never be published. +of your machine and should never be published. `honey.baseline.json` is +deliberately **not** gitignored: suppression pins are meant to be reviewed in +version control. ## Troubleshooting diff --git a/daily-cycle.sh b/daily-cycle.sh index 1f325e7..70ef105 100755 --- a/daily-cycle.sh +++ b/daily-cycle.sh @@ -24,6 +24,10 @@ CYCLE_LOG="$HONEY/cycle.log" # so a bare-env Local routine or cron job picks it up. env var > honey.conf > default. # shellcheck source=lib/load-config.sh . "$HONEY/lib/load-config.sh" +# Suppression baseline classifier, so the cycle's exit code honors pinned +# findings exactly as report.sh does (one source of truth for the verdict). +# shellcheck source=lib/baseline.sh +. "$HONEY/lib/baseline.sh" # A scheduler / Local routine may hand us a bare PATH. Ensure the dirs that # hold the scanners are findable BEFORE we invoke run-scan and the lenses — @@ -75,7 +79,9 @@ clog "manifest status=$STATUS findings=$TOTAL run_dir=$RUN_DIR" overall_rank() { # map a status to a severity rank for "worst wins" case "$1" in scan_error) echo 4;; exposed) echo 3;; incomplete) echo 2;; clean) echo 1;; *) echo 0;; esac } -OVERALL="$STATUS" +# CRASH tracks only lenses that RAN but wrote no verdict — a genuine crash must +# always escalate, and the baseline (which reads written JSON) can't see it. +CRASH="clean" if [ -d "$HONEY/lenses" ]; then for lens in "$HONEY/lenses"/*.sh; do [ -f "$lens" ] || continue # no lenses installed → loop body never runs @@ -89,17 +95,25 @@ if [ -d "$HONEY/lenses" ]; then # "skipped" JSON, so this only triggers on a genuine crash.) if [ ! -f "$LJSON" ] || ! jq -e '.status' "$LJSON" >/dev/null 2>&1; then clog "lens $lname: CRASHED (rc=$LRC, no valid verdict) — escalating to scan_error" - [ "$(overall_rank scan_error)" -gt "$(overall_rank "$OVERALL")" ] && OVERALL="scan_error" + CRASH="scan_error" continue fi LSTATUS="$(jq -r '.status' "$LJSON" 2>/dev/null)" LTOTAL="$(jq -r '.findings_total' "$LJSON" 2>/dev/null)" clog "lens $lname: status=$LSTATUS findings=$LTOTAL" - [ "$LSTATUS" = "skipped" ] && continue - [ "$(overall_rank "$LSTATUS")" -gt "$(overall_rank "$OVERALL")" ] && OVERALL="$LSTATUS" done fi -clog "=== cycle end (bumblebee=$STATUS overall=$OVERALL) ===" +# Effective verdict AFTER the suppression baseline: report.sh and this cycle +# must agree, so both go through the same classifier. baseline_effective_overall +# recomputes worst-wins over bumblebee + every written lens JSON, dropping +# suppressed findings (but never downgrading incomplete/scan_error, and always +# surfacing MUTATED pins). A crashed lens (no JSON) is then worst-wins'd in. +EFF="$(baseline_effective_overall "$RUN_DIR")" +OVERALL="$(printf '%s' "$EFF" | awk '{print $1}')" +SUPPCOUNTS="$(printf '%s' "$EFF" | cut -d' ' -f2-)" +[ "$(overall_rank "$CRASH")" -gt "$(overall_rank "$OVERALL")" ] && OVERALL="$CRASH" + +clog "=== cycle end (bumblebee=$STATUS overall=$OVERALL $SUPPCOUNTS) ===" # 0 when overall clean; 1 when anything (bumblebee or a lens) needs attention. [ "$OVERALL" = "clean" ] && exit 0 || exit 1 diff --git a/docs/BASELINE.plan.md b/docs/BASELINE.plan.md new file mode 100644 index 0000000..bb202ce --- /dev/null +++ b/docs/BASELINE.plan.md @@ -0,0 +1,219 @@ +# Suppression baseline (pin-and-diff) — design spec + +Status: **implemented** (this branch). Companion to [`WINDOWS.plan.md`](WINDOWS.plan.md). + +## 1. Problem + +honey's verdict is worst-wins across bumblebee + every active lens, and a lens +flips to `exposed` on the *first* finding at *any* severity. In practice this +buries the signal: a daily run over first-party, trusted agent skills can emit +100+ static-analysis findings — overwhelmingly false positives against +reference/documentation material (`references/*.md`, `LICENSE.txt`, MCP example +manifests) — and drag `OVERALL` to `EXPOSED` every single day. When every run +screams, the reader stops reading, and a *real* finding (a genuine CVE, a +malicious package) drowns in the noise. + +We need a way to acknowledge a finding we've reviewed and judged benign **once**, +so it stops contributing to the daily alarm — *without* going blind to the thing +honey exists to catch: that "trusted" content later turning malicious. + +## 2. The core idea: pin, don't mute + +A naive allowlist ("ignore skill X" / "trust this path") is the wrong model. +It fails in exactly the case honey defends against — a first-party plugin that +ships a malicious update next week keeps its allowlisted path and goes unseen. +**Trust-by-location is blindness-by-location.** + +Instead, honey suppresses by a **content hash** of the reviewed material. A +suppression entry means: + +> *"I reviewed this exact content and judged this finding benign. Tell me the +> instant the content changes."* + +The same hash that quiets a known-benign finding is a **tripwire**: if the +underlying file mutates, the hash no longer matches, and the finding resurfaces — +loudly, tagged `MUTATED`. This is precisely the recommended industry defense +against MCP **rug pulls** (benign-at-approval, malicious-after): *hash the +manifest on first sight and diff every run.* Suppression and change-detection +are the same mechanism. + +This reframes the feature from "mute noise" to "**pin-and-diff**": every +suppressed finding becomes a change detector on the content it vouches for. + +### What this design does and does NOT defend + +Robust against (the finding was flagged, then content changed): +- Manifest-alteration rug pulls, post-install source swaps, silent edits to a + previously-reviewed skill/lockfile → **hash mismatch → resurfaces as `MUTATED`.** + +Structurally blind to (nothing was ever flagged, so nothing to pin): +- Sleeper payloads (bytes unchanged, behavior triggered at runtime), + runtime-fetched remote includes, image/multimodal-embedded instructions, + semantic paraphrase that never tripped a pattern. + +The baseline neither helps nor hurts the adversary on that second class — those +evades live *outside the scanned surface*. The design's one hard rule +(§6) is that a suppressed run must **never render as a bare all-clear**: the +counts are always shown so "clean" can't quietly imply "nothing was hidden over +a surface we never fully scanned." + +## 3. Fingerprint + +A finding is identified by a 4-part key plus a content guard. Modeled on +Semgrep's `(rule, path, syntactic-context, index)` and deliberately *unlike* +gitleaks' location-only fingerprint (which breaks under line/path churn). + +| Field | Source (lens finding) | Source (bumblebee) | Notes | +|---|---|---|---| +| `scanner` | lens name | `"bumblebee"` | which scanner produced it | +| `rule` | `title` (+ `ref`) | `catalog_id` + `package_name` | stable rule/campaign identity | +| `location` | `location` (`file:line`) | `source_file` | **stored `~`-relative** for portability | +| `index` | occurrence # among identical `(scanner,rule,location)` | same | disambiguates dup findings (Semgrep lesson) | +| `content_hash` | `sha256(file at location)` | `sha256(source_file)` | the pin-and-diff guard | + +- **Location normalization:** an absolute path under `$HOME`/`%USERPROFILE%` is + stored as `~/...`. This keys on machine-relative paths (never absolute), so a + baseline survives a home-dir move and reads sanely in a PR diff. +- **Content unit = the whole referenced file.** Coarse on purpose: any edit to a + reviewed skill file resurfaces *all* its pins for re-review. For "trusted but + static" content that is the safe direction — and an edit is exactly a rug-pull + moment. If the location has no on-disk file, honey falls back to hashing the + finding's own canonical JSON (catches finding-text mutation, not file mutation) + and marks the entry `content_source: "finding"`. +- Hashing is **raw bytes** (see §7 on Unicode) via a portable helper: + `sha256sum` → `shasum -a 256` → `openssl dgst -sha256` (bash); + `Get-FileHash -Algorithm SHA256` (PowerShell). + +## 4. Classification + +For each **active** finding at report time, honey computes its key and looks up a +matching baseline entry: + +| Baseline entry | File hash vs. entry | Result | In verdict? | +|---|---|---|---| +| none | — | `active` | yes (normal) | +| present, not expired | **match** | `suppressed` | **no** (listed in a Suppressed footer) | +| present, not expired | **mismatch** | `mutated` | **yes**, escalated, tagged `MUTATED` | +| present, **expired** | match | `active` | yes, tagged `expired-suppression` | + +- `suppressed` findings are removed from the active counts but **retained in the + report** under a per-scanner "suppressed (N)" note and an optional footer. + Never deleted from `lens-*.json` / `findings.ndjson` — suppression is a + **report-layer re-rank**, honey's standing discipline ("never hide data"). +- `mutated` findings can never be suppressed; they force the scanner to + `exposed` and OVERALL away from clean. +- Expiry: default **90 days** from `added` (configurable per entry, or `never`). + Expired entries resurface so "temporarily accepted" can't silently become + permanent blindness (trivy/detect-secrets lesson). + +## 5. Verdict + +OVERALL is recomputed **after** suppression, using the same worst-wins ranking: + +- A scanner whose findings are *all* suppressed ranks as `clean` for the verdict, + but the report prints `✓ … (all N suppressed)` — not a bare clean. +- `incomplete` / `scan_error` / `skipped` are **never** touched by the baseline + (suppression only removes `exposed` findings; it can't paper over partial + coverage or a crash). +- The verdict line always carries the tallies when any pin applied: + - `OVERALL: CLEAN (12 suppressed)` — exit 0. + - `OVERALL: EXPOSED — … (12 suppressed, 2 mutated)` — exit 1. + - Any `mutated` > 0 ⇒ cannot be clean. + +`report.sh`/`report.ps1` render it; `daily-cycle` uses the same shared +classifier for its exit code so the two never disagree. + +## 6. Non-negotiable safety rules + +1. **Never a bare all-clear when pins applied.** The suppressed/mutated counts + are always on the verdict line. "Clean" must never imply "nothing hidden." +2. **Content hash is mandatory** — no location-only suppression. A content swap + at a pinned location must resurface, never stay quiet. +3. **A scan_error or incomplete lens is never downgraded** by the baseline. +4. **A lens crash never creates or clears baseline state** (Semgrep's baseline + bug: a per-rule failure must not mass-suppress or mass-resurface). +5. **Deterministic fingerprints.** Stable ordering + occurrence index so benign + re-scans don't cycle findings in/out (Semgrep flakiness lesson). +6. **bumblebee suppression is opt-in and loud.** `add --all` excludes bumblebee; + pinning a known-compromised-package match requires an explicit + `--scanner bumblebee`, and such entries are rendered distinctly. +7. **The baseline file is committed and reviewable.** Baseline edits belong in + `git diff`/PR review — that auditability is itself an anti-gaming control. + +## 7. Unicode / evasion hardening + +Static scanners are evaded by invisible Unicode (tag chars U+E0000–E007F), +homoglyphs, and bidi controls. Two consequences for the baseline: + +- **Hash raw bytes**, so a smuggled payload — once flagged — is covered by the + pin and any change to it resurfaces. +- **Do not normalize away invisibles in the match key.** A homoglyph/invisible + edit changes the file bytes ⇒ hash mismatch ⇒ `mutated`. That is the desired + behavior; we must not let NFC-folding silently re-suppress a tampered file. + +(Detecting smuggled codepoints in the first place is the *lens's* job, tracked +separately in §9; the baseline's contract is only "once flagged, stay pinned to +exact bytes.") + +## 8. Baseline file + +`honey.baseline.json` at the repo root (override: `HONEY_BASELINE`). Committed, +not gitignored. Starts as `{"version":1,"entries":[]}`. + +```json +{ + "version": 1, + "entries": [ + { + "scanner": "skillspector", + "rule": "PE3 Credential Access", + "location": "~/.claude/plugins/marketplaces/.../auth.md:73", + "index": 0, + "content_hash": "sha256:1a2b…", + "content_source": "file", + "severity": "high", + "reason": "first-party Anthropic marketplace reference doc; describes credential-file patterns, not executable", + "added": "2026-07-07", + "expires": "2026-10-05", + "added_by": "eric" + } + ] +} +``` + +## 9. Management CLI — `honey-baseline.sh` / `win/honey-baseline.ps1` + +| Command | Purpose | +|---|---| +| `status [RUN_DIR]` | dry-run: how many findings would be suppressed / mutated / expired / active for a run | +| `add [RUN_DIR] --reason STR [--expires DAYS\|never] [--added-by NAME]` | capture matching findings + current file hashes into the baseline | +| `list [--expired\|--active]` | list entries (flag expired) | +| `remove ` | drop matching entries | +| `prune` | remove expired entries | + +`` for `add`/`remove`: `--all` (excludes bumblebee), `--scanner NAME`, +`--severity SEV`, `--rule 'text'`, `--location 'path-substr'` (combinable, AND). + +The CLI is read-mostly and never changes system state — it only edits +`honey.baseline.json`, which the user reviews and commits. + +## 10. Files touched + +Shared logic lives in one place per platform so `report`, `daily-cycle`, and the +CLI agree: + +- **bash:** new `lib/baseline.sh` (hash, normalize, load, classify, filter, + `baseline_effective_overall`), new `honey-baseline.sh`; edits to `report.sh`, + `daily-cycle.sh`, `doctor.sh`, `.gitignore`, new empty `honey.baseline.json`. +- **PowerShell:** new `win/lib/Baseline.psm1` (mirror), new + `win/honey-baseline.ps1`; edits to `win/report.ps1`, `win/daily-cycle.ps1`, + `win/doctor.ps1`, `win/TESTING.md`. +- **docs:** this file; `README.md`, `triage-guide.md`, `routine-prompt.md`. + +## 11. Explicitly out of scope (follow-ups) + +- New scanner lenses (Snyk/Invariant `mcp-scan` for the MCP-manifest surface; + multimodal/OCR lens for image-embedded payloads). The baseline can only + suppress what a lens flagged; widening the scanned surface is separate work. +- Runtime/behavioral detection of sleeper rug pulls and remote includes. +- Invisible-codepoint detection in the lenses (§7). diff --git a/doctor.sh b/doctor.sh index 78f4a14..4c3a025 100755 --- a/doctor.sh +++ b/doctor.sh @@ -8,6 +8,9 @@ set -uo pipefail HONEY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" . "$HONEY_DIR/lib/preflight.sh" +HONEY="${HONEY:-$HONEY_DIR}" +# shellcheck source=lib/baseline.sh +. "$HONEY_DIR/lib/baseline.sh" echo "honey doctor — checking dependencies" echo @@ -55,6 +58,20 @@ if [ -d "$HONEY/lenses" ] && ls "$HONEY"/lenses/*.sh >/dev/null 2>&1; then esac done fi +# Suppression baseline — informational; never affects pass/fail. Shows how many +# findings you've pinned as reviewed-benign and whether any pins have expired. +BFILE="$(honey_baseline_file)" +echo +echo "suppression baseline:" +if [ -f "$BFILE" ]; then + BN="$(honey_baseline_entries | jq 'length' 2>/dev/null || echo 0)" + BEXP="$(honey_baseline_entries | jq --arg t "$(honey_today)" '[.[]|select(.expires!="never" and .expires < $t)]|length' 2>/dev/null || echo 0)" + ok "baseline present ($BN pin(s)) — $BFILE" + [ "${BEXP:-0}" -gt 0 ] && hint "$BEXP pin(s) expired; review with ./honey-baseline.sh list --expired, then ./honey-baseline.sh prune" +else + ok "no baseline yet (all findings active) — create pins with ./honey-baseline.sh add" +fi + echo if [ "$fails" -eq 0 ]; then printf '%sAll checks passed — honey is ready.%s Run: ./daily-cycle.sh\n' "$C_OK" "$C_OFF" diff --git a/honey-baseline.sh b/honey-baseline.sh new file mode 100755 index 0000000..781e9df --- /dev/null +++ b/honey-baseline.sh @@ -0,0 +1,240 @@ +#!/usr/bin/env bash +# +# honey/honey-baseline.sh — manage the pin-and-diff suppression baseline. +# +# A baseline entry acknowledges a reviewed finding by (scanner, rule, location, +# occurrence index) AND a sha256 of the referenced content. A matching finding +# whose content still hashes the same is SUPPRESSED (dropped from the verdict, +# still shown). If the content changes, the finding resurfaces as MUTATED. +# See docs/BASELINE.plan.md. +# +# Commands: +# status [RUN_DIR] dry-run: suppressed/mutated/expired/active tally +# add [RUN_DIR] --reason STR [--expires DAYS|never] [--added-by NAME] +# list [--expired|--active] show entries (flags expired) +# remove drop matching entries +# prune remove expired entries +# +# (combinable, AND): --all (excludes bumblebee) | --scanner NAME | +# --severity SEV | --rule TEXT | --location SUBSTR +# +# This tool NEVER changes system state — it only edits honey.baseline.json, +# which you review and commit. honey only reports; it never fixes for you. + +set -uo pipefail +HONEY="${HONEY:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" +# shellcheck source=lib/load-config.sh +. "$HONEY/lib/load-config.sh" +# shellcheck source=lib/baseline.sh +. "$HONEY/lib/baseline.sh" + +BASELINE="$(honey_baseline_file)" + +if [ -t 1 ]; then B=$'\033[1m'; D=$'\033[2m'; R=$'\033[31m'; G=$'\033[32m'; O=$'\033[0m' +else B=""; D=""; R=""; G=""; O=""; fi + +die() { echo "honey-baseline: $*" >&2; exit 2; } +usage() { sed -n '3,26p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; } + +ensure_baseline() { + [ -f "$BASELINE" ] && return 0 + echo "$D(creating $BASELINE)$O" >&2 + printf '{"version":1,"entries":[]}\n' > "$BASELINE" +} + +# Resolve a RUN_DIR argument (or the `latest` symlink) from the head of "$@". +# Sets RUN_DIR and shifts it off if it looked like a path. +RUN_DIR="" +resolve_run_dir() { + if [ "$#" -gt 0 ] && [ -d "$1" ] && [ -f "$1/manifest.json" ]; then + RUN_DIR="$1"; return 1 # caller must shift + fi + RUN_DIR="$(readlink "$HONEY/latest" 2>/dev/null || echo "$HONEY/latest")" + [ -d "$RUN_DIR" ] || die "no run dir (pass one, or run a scan so $HONEY/latest exists)" + return 0 +} + + +# --- commands --------------------------------------------------------------- + +cmd_status() { + local shifted; resolve_run_dir "$@"; shifted=$? + [ "$shifted" -eq 1 ] && shift + echo "${B}baseline status${O} ${D}(run: $RUN_DIR)${O}" + echo "${D}baseline: $BASELINE ($(honey_baseline_entries | jq 'length') entr$( [ "$(honey_baseline_entries | jq 'length')" = "1" ] && echo y || echo ies ))${O}" + local all; all="$(honey_classify_run "$RUN_DIR")" + local s m e a + s="$(printf '%s\n' "$all" | jq -r 'select(._class=="suppressed")|1' 2>/dev/null | grep -c .)" + m="$(printf '%s\n' "$all" | jq -r 'select(._class=="mutated")|1' 2>/dev/null | grep -c .)" + e="$(printf '%s\n' "$all" | jq -r 'select(._class=="expired")|1' 2>/dev/null | grep -c .)" + a="$(printf '%s\n' "$all" | jq -r 'select(._class=="active")|1' 2>/dev/null | grep -c .)" + printf ' %sactive: %s%s (contribute to the verdict)\n' "$B" "$a" "$O" + printf ' suppressed: %s %s(pinned & unchanged — hidden from the verdict)%s\n' "$s" "$D" "$O" + printf ' %smutated: %s%s %s(pinned content CHANGED — resurfaced, review now)%s\n' "$R" "$m" "$O" "$D" "$O" + printf ' expired: %s %s(pin past its expiry — resurfaced)%s\n' "$e" "$D" "$O" + [ "$m" -gt 0 ] && echo "${R}⚠ ${m} mutated finding(s): a reviewed file changed since it was pinned.${O}" +} + +cmd_list() { + local flt="."; case "${1:-}" in + --expired) flt='map(select((.expires!="never") and (.expires < (now|strftime("%Y-%m-%d")))))' ;; + --active) flt='map(select((.expires=="never") or (.expires >= (now|strftime("%Y-%m-%d")))))' ;; + "" ) ;; *) die "list: unknown option $1" ;; + esac + local today; today="$(honey_today)" + honey_baseline_entries | jq -r --arg t "$today" "$flt"' | to_entries[] | + .key as $i | .value | + (if (.expires!="never" and .expires < $t) then "EXPIRED " else " " end) + + "[\(.scanner)] \(.rule)\n \(.location) (index \(.index // 0))\n reason: \(.reason // "") added: \(.added // "?") expires: \(.expires // "never")"' \ + 2>/dev/null + local n; n="$(honey_baseline_entries | jq 'length')" + echo "${D}${n} entr$( [ "$n" = 1 ] && echo y || echo ies ) in $BASELINE${O}" +} + +cmd_prune() { + ensure_baseline + local before after today; today="$(honey_today)" + before="$(honey_baseline_entries | jq 'length')" + local tmp; tmp="$(mktemp)" + jq --arg t "$today" '.entries |= map(select(.expires=="never" or .expires >= $t))' "$BASELINE" > "$tmp" && mv "$tmp" "$BASELINE" + after="$(honey_baseline_entries | jq 'length')" + echo "pruned $((before-after)) expired entr$( [ "$((before-after))" = 1 ] && echo y || echo ies ); $after remain." +} + +# Parse the shared flags into globals + a jq predicate. Consumes the +# recognized flags from "$@" and leaves the rest via the REST array. +F_ALL=0; F_SCANNER=""; F_SEV=""; F_RULE=""; F_LOC="" +REST=() +parse_filter() { + F_ALL=0; F_SCANNER=""; F_SEV=""; F_RULE=""; F_LOC=""; REST=() + while [ "$#" -gt 0 ]; do + case "$1" in + --all) F_ALL=1; shift ;; + --scanner) F_SCANNER="${2:-}"; shift 2 ;; + --severity) F_SEV="${2:-}"; shift 2 ;; + --rule) F_RULE="${2:-}"; shift 2 ;; + --location) F_LOC="${2:-}"; shift 2 ;; + *) REST+=("$1"); shift ;; + esac + done +} +# Did the user give ANY selector? (guards against an accidental match-all.) +filter_given() { [ "$F_ALL" = 1 ] || [ -n "$F_SCANNER" ] || [ -n "$F_SEV" ] || [ -n "$F_RULE" ] || [ -n "$F_LOC" ]; } + +# jq predicate over a classified finding (has ._scanner,.severity,.rule,._loc). +# --all excludes bumblebee (pinning a known-compromised match must be explicit). +# shellcheck disable=SC2016 # this is a jq program; $vars are jq args, not shell +jq_pred=' + ($all==1 and ._scanner=="bumblebee" | not) and + ($scanner=="" or ._scanner==$scanner) and + ($sev=="" or (.severity//"")==$sev) and + ($rule=="" or ((.rule//"")|contains($rule))) and + ($loc=="" or ((._loc//"")|contains($loc)))' + +cmd_add() { + # RUN_DIR may be the first arg; peel it before parsing flags. + if [ "$#" -gt 0 ] && [ -d "$1" ] && [ -f "$1/manifest.json" ]; then RUN_DIR="$1"; shift + else resolve_run_dir; fi + local reason="" expires_days="90" added_by="${USER:-unknown}" args=() + # Split honey-baseline-specific flags from the shared filter flags. + while [ "$#" -gt 0 ]; do + case "$1" in + --reason) reason="${2:-}"; shift 2 ;; + --expires) expires_days="${2:-}"; shift 2 ;; + --added-by) added_by="${2:-}"; shift 2 ;; + *) args+=("$1"); shift ;; + esac + done + parse_filter "${args[@]:-}" + [ -n "$reason" ] || die "add: --reason is required (why is this finding benign?)" + filter_given || die "add: refusing to pin with no selector — pass --all or a --scanner/--severity/--rule/--location filter" + + local expires + if [ "$expires_days" = "never" ]; then expires="never" + else + case "$expires_days" in (*[!0-9]*) die "add: --expires must be a number of days or 'never'";; esac + expires="$(date -u -v +"${expires_days}"d +%Y-%m-%d 2>/dev/null || date -u -d "+${expires_days} days" +%Y-%m-%d 2>/dev/null)" + [ -n "$expires" ] || die "add: could not compute expiry date" + fi + local added; added="$(honey_today)" + ensure_baseline + + # Select matching findings, compute a content hash for each NOW, emit entries. + local sel new count + sel="$(honey_classify_run "$RUN_DIR" \ + | jq -c --argjson all "$F_ALL" --arg scanner "$F_SCANNER" --arg sev "$F_SEV" \ + --arg rule "$F_RULE" --arg loc "$F_LOC" "select($jq_pred)")" + count="$(printf '%s\n' "$sel" | grep -c .)" + [ "$count" -gt 0 ] || { echo "add: no findings matched that filter in $RUN_DIR"; return 0; } + + local newfile; newfile="$(mktemp)" + : > "$newfile" + printf '%s\n' "$sel" | while IFS= read -r f; do + [ -n "$f" ] || continue + local scanner rule loc idx csource chash locfile absfile + scanner="$(printf '%s' "$f" | jq -r '._scanner')" + rule="$(printf '%s' "$f" | jq -r '.rule // ""')" + loc="$(printf '%s' "$f" | jq -r '._loc // ""')" + idx="$(printf '%s' "$f" | jq -r '._index // 0')" + locfile="$(honey_loc_file "$loc")" + absfile="$(honey_untildify "$locfile")" + if [ -f "$absfile" ]; then + chash="$(honey_hash_file "$absfile")"; csource="file" + else + chash="$(printf '%s' "$f" | jq -Sc 'with_entries(select(.key|startswith("_")|not))' | honey_hash_stdin)"; csource="finding" + fi + printf '%s' "$f" | jq -c \ + --arg sc "$scanner" --arg r "$rule" --arg l "$loc" --argjson i "$idx" \ + --arg ch "$chash" --arg cs "$csource" --arg rs "$reason" \ + --arg ad "$added" --arg ex "$expires" --arg by "$added_by" \ + '{scanner:$sc, rule:$r, location:$l, index:$i, content_hash:$ch, content_source:$cs, + severity:(.severity//"unknown"), reason:$rs, added:$ad, expires:$ex, added_by:$by}' >> "$newfile" + done + + new="$(jq -sc '.' "$newfile")"; rm -f "$newfile" + # Merge: new entries replace any existing pin with the same identity key. + local tmp; tmp="$(mktemp)" + jq --argjson new "$new" ' + def key: "\(.scanner)\(.rule)\(.location)\(.index // 0)"; + ($new | map(key)) as $nk + | .entries = ((.entries // []) | map(select((key) as $k | ($nk | index($k) | not))) + $new) + ' "$BASELINE" > "$tmp" && mv "$tmp" "$BASELINE" + local bcount; bcount="$(printf '%s' "$new" | jq 'map(select(.scanner=="bumblebee"))|length')" + echo "${G}pinned ${count} finding(s)${O} into $BASELINE (expires: $expires)." + [ "$bcount" -gt 0 ] && echo "${R}⚠ ${bcount} of these are bumblebee (known-compromised) matches — make sure that is intended.${O}" + echo "${D}Review the diff and commit honey.baseline.json.${O}" +} + +cmd_remove() { + ensure_baseline + parse_filter "$@" + filter_given || die "remove: pass a selector (--all/--scanner/--severity/--rule/--location)" + local before after tmp; before="$(honey_baseline_entries | jq 'length')" + tmp="$(mktemp)" + # For removal, --all means "every non-bumblebee entry"; reuse the same pred + # against baseline entries (they carry .scanner/.rule/.location, no ._loc). + jq --argjson all "$F_ALL" --arg scanner "$F_SCANNER" --arg sev "$F_SEV" \ + --arg rule "$F_RULE" --arg loc "$F_LOC" ' + .entries |= map(select( + (($all==1 and .scanner=="bumblebee" | not) and + ($scanner=="" or .scanner==$scanner) and + ($sev=="" or (.severity//"")==$sev) and + ($rule=="" or ((.rule//"")|contains($rule))) and + ($loc=="" or ((.location//"")|contains($loc)))) | not))' \ + "$BASELINE" > "$tmp" && mv "$tmp" "$BASELINE" + after="$(honey_baseline_entries | jq 'length')" + echo "removed $((before-after)) entr$( [ "$((before-after))" = 1 ] && echo y || echo ies ); $after remain." +} + +# --- dispatch --------------------------------------------------------------- + +cmd="${1:-}"; [ "$#" -gt 0 ] && shift || true +case "$cmd" in + status) cmd_status "$@" ;; + add) cmd_add "$@" ;; + list) cmd_list "$@" ;; + remove|rm) cmd_remove "$@" ;; + prune) cmd_prune ;; + -h|--help|help|"") usage 0 ;; + *) die "unknown command '$cmd' (try: status add list remove prune)" ;; +esac diff --git a/honey.baseline.json b/honey.baseline.json new file mode 100644 index 0000000..4717ec5 --- /dev/null +++ b/honey.baseline.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "note": "honey suppression baseline. Each entry pins a reviewed-benign finding by (scanner, rule, ~-relative location, occurrence index) AND a sha256 of the referenced content; a matching finding whose content still hashes the same is suppressed, and a pinned file whose content CHANGES resurfaces as MUTATED. Manage with ./honey-baseline.sh (add/list/remove/prune/status). This file is committed and reviewable on purpose. See docs/BASELINE.plan.md.", + "entries": [] +} diff --git a/lib/baseline.sh b/lib/baseline.sh new file mode 100755 index 0000000..0afb4d2 --- /dev/null +++ b/lib/baseline.sh @@ -0,0 +1,254 @@ +#!/usr/bin/env bash +# lib/baseline.sh — honey's pin-and-diff suppression baseline (shared core). +# +# Sourced by report.sh, daily-cycle.sh, and honey-baseline.sh so all three agree +# on one classification. See docs/BASELINE.plan.md for the full design. +# +# The model: a baseline entry pins a finding by (scanner, rule, ~-relative +# location, occurrence index) AND a sha256 of the referenced file's content. +# A live finding that matches a pin AND whose file hash still matches is +# SUPPRESSED (dropped from the verdict, kept in the report). If the file hash +# has CHANGED, the finding is MUTATED - it resurfaces loudly (the rug-pull +# tripwire). An expired pin lets its finding resurface as normal. +# +# Portability: pure bash 3.2 + jq. No associative arrays, no ${x^^}. Occurrence +# indices and path tildify are done in jq (deterministic), not in shell. + +# --- config ----------------------------------------------------------------- + +# Absolute path to the baseline file. Override with HONEY_BASELINE. +honey_baseline_file() { + if [ -n "${HONEY_BASELINE:-}" ]; then printf '%s' "$HONEY_BASELINE"; return; fi + local root="${HONEY:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" + printf '%s/honey.baseline.json' "$root" +} + +# Emit the entries array (or [] when the file is absent/empty/malformed). +honey_baseline_entries() { + local f; f="$(honey_baseline_file)" + [ -f "$f" ] || { printf '[]'; return; } + jq -c '.entries // []' "$f" 2>/dev/null || printf '[]' +} + +# --- path helpers ----------------------------------------------------------- + +# ~/foo -> $HOME/foo (inverse of the jq tildify used during classification). +# The literal ~ is intentional here (we match a stored "~/…" prefix, we are not +# asking the shell to expand a tilde), so SC2088 does not apply. +honey_untildify() { + local p="$1" + # shellcheck disable=SC2088 # literal ~ prefixes are matched, not expanded + case "$p" in + "~/"*) printf '%s/%s' "$HOME" "${p#"~/"}" ;; + "~") printf '%s' "$HOME" ;; + *) printf '%s' "$p" ;; + esac +} + +# Split a "file:line" location into its FILE part (LINE dropped). A trailing +# : is the line; the rest (which may contain a Windows drive colon) is +# the file. +honey_loc_file() { + case "$1" in + *:[0-9]*) printf '%s' "${1%:*}" ;; + *) printf '%s' "$1" ;; + esac +} + +# --- hashing ---------------------------------------------------------------- + +# Portable sha256 of a file's raw bytes -> "sha256:HEX" (empty on failure). +# Raw bytes on purpose (docs/BASELINE.plan.md section 7): a smuggled-Unicode +# edit changes the bytes -> hash mismatch -> resurfaces. No NFC folding. +honey_hash_file() { + local f="$1" h="" + [ -f "$f" ] && [ -r "$f" ] || { printf ''; return; } + if command -v sha256sum >/dev/null 2>&1; then + h="$(sha256sum "$f" 2>/dev/null | awk '{print $1}')" + elif command -v shasum >/dev/null 2>&1; then + h="$(shasum -a 256 "$f" 2>/dev/null | awk '{print $1}')" + elif command -v openssl >/dev/null 2>&1; then + h="$(openssl dgst -sha256 "$f" 2>/dev/null | awk '{print $NF}')" + fi + [ -n "$h" ] && printf 'sha256:%s' "$h" || printf '' +} + +# sha256 of a string on stdin -> "sha256:HEX". Used for the finding-JSON +# fallback when a finding's location is not an on-disk file. +honey_hash_stdin() { + local h="" + if command -v sha256sum >/dev/null 2>&1; then + h="$(sha256sum 2>/dev/null | awk '{print $1}')" + elif command -v shasum >/dev/null 2>&1; then + h="$(shasum -a 256 2>/dev/null | awk '{print $1}')" + elif command -v openssl >/dev/null 2>&1; then + h="$(openssl dgst -sha256 2>/dev/null | awk '{print $NF}')" + fi + [ -n "$h" ] && printf 'sha256:%s' "$h" || printf '' +} + +# Today (UTC) as YYYY-MM-DD. ISO dates compare correctly as strings. +honey_today() { date -u +%Y-%m-%d; } + +# --- classification --------------------------------------------------------- +# +# _honey_classify SCANNER < findings-array-json > classified-ndjson +# +# Input on stdin: a JSON ARRAY of finding objects, each carrying `.rule` and +# `._rawloc` (absolute match location) plus whatever original fields the +# renderer needs (including the original `.location` for display). +# Output: one JSON object per line, the input finding plus: +# _loc ~-relative match location (portable key; gitleaks lesson) +# _index occurrence # among identical (rule,_loc) - deterministic +# _class active | suppressed | mutated | expired +# _reason the pin's reason, else "" +# One jq pass does tildify + occurrence index; the shell loop only hashes the +# referenced file and looks up the pin. +_honey_classify() { + local scanner="$1" + local entries today + entries="$(honey_baseline_entries)" + today="$(honey_today)" + + jq -c --arg home "$HOME" ' + def tildify($p): + if ($p | startswith($home + "/")) then "~/" + ($p[($home|length)+1:]) + elif ($p == $home) then "~" + else $p end; + [ .[] | . + {_loc: tildify(._rawloc // "")} | del(._rawloc) ] as $arr + | reduce range(0; ($arr|length)) as $i ({seen:{}, out:[]}; + (($arr[$i].rule // "") + " " + ($arr[$i]._loc // "")) as $k + | .seen[$k] = ((.seen[$k] // -1) + 1) + | .out += [ $arr[$i] + {_index: .seen[$k]} ] + ) | .out[]' 2>/dev/null \ + | while IFS= read -r finding; do + [ -n "$finding" ] || continue + local rule loc idx locfile absfile curhash entry expires csource cmp class reason + rule="$(printf '%s' "$finding" | jq -r '.rule // ""')" + loc="$(printf '%s' "$finding" | jq -r '._loc // ""')" + idx="$(printf '%s' "$finding" | jq -r '._index // 0')" + + entry="$(printf '%s' "$entries" | jq -c --arg s "$scanner" --arg r "$rule" \ + --arg l "$loc" --argjson i "$idx" \ + 'map(select(.scanner==$s and .rule==$r and .location==$l and ((.index // 0)==$i))) | .[0] // empty' 2>/dev/null)" + + if [ -z "$entry" ]; then + printf '%s' "$finding" | jq -c '. + {_class:"active",_reason:""}' + continue + fi + + # A pin exists. Recompute the content hash the same way `add` captured it. + csource="$(printf '%s' "$entry" | jq -r '.content_source // "file"')" + if [ "$csource" = "finding" ]; then + curhash="$(printf '%s' "$finding" | jq -Sc 'with_entries(select(.key|startswith("_")|not))' | honey_hash_stdin)" + else + locfile="$(honey_loc_file "$loc")" + absfile="$(honey_untildify "$locfile")" + curhash="$(honey_hash_file "$absfile")" + fi + + expires="$(printf '%s' "$entry" | jq -r '.expires // "never"')" + reason="$(printf '%s' "$entry" | jq -r '.reason // ""')" + cmp="$(printf '%s' "$entry" | jq -r '.content_hash // ""')" + + if [ -n "$curhash" ] && [ "$curhash" = "$cmp" ]; then + # Hash matches the reviewed content: suppress - unless the pin expired. + if [ "$expires" != "never" ] && [ "$expires" \< "$today" ]; then + class="expired" + else + class="suppressed" + fi + else + # Content changed since review (or file gone): the tripwire. Resurface. + class="mutated" + fi + printf '%s' "$finding" | jq -c --arg c "$class" --arg r "$reason" '. + {_class:$c,_reason:$r}' + done +} + +# Classify a lens's normalized findings (from lens-.json). rule = title; +# match location = finding.location. Prints classified NDJSON. +honey_classify_lens() { + local scanner="$1" lensjson="$2" + [ -f "$lensjson" ] || return 0 + jq -c '[ (.findings // [])[] | . + {rule:(.title // ""), _rawloc:(.location // "")} ]' "$lensjson" 2>/dev/null \ + | _honey_classify "$scanner" +} + +# Classify bumblebee findings.ndjson. rule = catalog_id|package; match location +# = source_file. Keeps original record fields for rendering. +honey_classify_bumblebee() { + local ndjson="$1" + [ -f "$ndjson" ] || return 0 + jq -sc '[ .[] | . + {rule:((.catalog_id // "") + "|" + (.package_name // "")), _rawloc:(.source_file // "")} ]' "$ndjson" 2>/dev/null \ + | _honey_classify "bumblebee" +} + +# Classify EVERY scanner in a run dir, tagging each finding with `_scanner`. +# Emits NDJSON. Used by the management CLI (status/add/remove). +honey_classify_run() { + local run_dir="$1" lj name + if [ -f "$run_dir/findings.ndjson" ]; then + honey_classify_bumblebee "$run_dir/findings.ndjson" \ + | jq -c '. + {_scanner:"bumblebee"}' + fi + for lj in "$run_dir"/lens-*.json; do + [ -f "$lj" ] || continue + name="$(jq -r '.lens // ""' "$lj" 2>/dev/null)" + [ -n "$name" ] || continue + honey_classify_lens "$name" "$lj" \ + | jq -c --arg s "$name" '. + {_scanner:$s}' + done +} + +# --- verdict ---------------------------------------------------------------- + +# baseline_effective_overall RUN_DIR +# Echoes: "STATUS suppressed=N mutated=M expired=K" +# STATUS is the worst-wins status across bumblebee + lenses AFTER suppression. +# Only `exposed` findings are re-evaluated; incomplete/scan_error/skipped pass +# through untouched (a baseline never downgrades partial coverage or a crash). +baseline_effective_overall() { + local run_dir="$1" + local manifest="$run_dir/manifest.json" + local sup=0 mut=0 exp=0 overall="clean" + _rank() { case "$1" in scan_error) echo 4;; exposed) echo 3;; incomplete) echo 2;; clean|skipped) echo 1;; *) echo 0;; esac; } + _worse() { [ "$(_rank "$2")" -gt "$(_rank "$1")" ] && printf '%s' "$2" || printf '%s' "$1"; } + _cnt() { printf '%s\n' "$1" | jq -r "select(._class==\"$2\")|._class" 2>/dev/null | grep -c . ; } + + # bumblebee + if [ -f "$manifest" ]; then + local bst; bst="$(jq -r '.status // "unknown"' "$manifest" 2>/dev/null)" + if [ "$bst" = "exposed" ] && [ -f "$run_dir/findings.ndjson" ]; then + local cls s m e a + cls="$(honey_classify_bumblebee "$run_dir/findings.ndjson")" + s="$(_cnt "$cls" suppressed)"; m="$(_cnt "$cls" mutated)"; e="$(_cnt "$cls" expired)" + a="$(printf '%s\n' "$cls" | jq -r 'select(._class!="suppressed")|._class' 2>/dev/null | grep -c .)" + sup=$((sup+s)); mut=$((mut+m)); exp=$((exp+e)) + [ "$a" -gt 0 ] && overall="$(_worse "$overall" exposed)" + else + overall="$(_worse "$overall" "$bst")" + fi + fi + + # lenses + local lj + for lj in "$run_dir"/lens-*.json; do + [ -f "$lj" ] || continue + local name lst + name="$(jq -r '.lens // ""' "$lj" 2>/dev/null)" + lst="$(jq -r '.status // "unknown"' "$lj" 2>/dev/null)" + if [ "$lst" = "exposed" ]; then + local cls s m e a + cls="$(honey_classify_lens "$name" "$lj")" + s="$(_cnt "$cls" suppressed)"; m="$(_cnt "$cls" mutated)"; e="$(_cnt "$cls" expired)" + a="$(printf '%s\n' "$cls" | jq -r 'select(._class!="suppressed")|._class' 2>/dev/null | grep -c .)" + sup=$((sup+s)); mut=$((mut+m)); exp=$((exp+e)) + [ "$a" -gt 0 ] && overall="$(_worse "$overall" exposed)" + else + overall="$(_worse "$overall" "$lst")" + fi + done + + printf '%s suppressed=%s mutated=%s expired=%s' "$overall" "$sup" "$mut" "$exp" +} diff --git a/report.sh b/report.sh index 84afc3f..d4dc1af 100755 --- a/report.sh +++ b/report.sh @@ -8,6 +8,12 @@ # the analysis for the no-Claude path; the Claude routine produces richer, # tailored prose, but the facts and standard fixes are all here deterministically. # +# The pin-and-diff suppression baseline (honey.baseline.json) is applied here at +# the report layer: findings pinned as reviewed-benign AND still matching their +# recorded content hash are SUPPRESSED (dropped from the verdict, still counted); +# a pinned file whose content CHANGED resurfaces as MUTATED. Raw run records are +# never modified — suppression is a re-rank, not a deletion. See docs/BASELINE.plan.md. +# # RUN_DIR defaults to the `latest` symlink. Exit 0 clean / 1 needs attention. set -uo pipefail @@ -18,6 +24,9 @@ FINDINGS="$RUN_DIR/findings.ndjson" [ -f "$MANIFEST" ] || { echo "report: no manifest at $MANIFEST" >&2; exit 1; } +# shellcheck source=lib/baseline.sh +. "$HONEY/lib/baseline.sh" + if [ -t 1 ]; then B=$'\033[1m'; D=$'\033[2m'; R=$'\033[31m'; Y=$'\033[33m'; G=$'\033[32m'; O=$'\033[0m' else B=""; D=""; R=""; Y=""; G=""; O=""; fi @@ -28,6 +37,9 @@ COMPLETED="$(j '.scan_completed')"; CATDIR="$(j '.catalog_dir')" FILES="$(jq -r '.files_considered // "?"' "$RUN_DIR/summary.json" 2>/dev/null)" [ -z "$FILES" ] && FILES="?" # summary.json may be empty/absent on scan_error +# Suppression tallies, accumulated across bumblebee + every lens as we render. +SUP=0; MUT=0; EXP=0 + # Remediation guidance per ecosystem. Generic but correct starting points; # always "run manually" — honey never changes state. remediation() { # ecosystem package version @@ -47,9 +59,20 @@ remediation() { # ecosystem package version # severity → rank, for "worst wins" across bumblebee + every lens. rank() { case "$1" in scan_error) echo 4;; exposed) echo 3;; incomplete) echo 2;; clean|skipped) echo 1;; *) echo 0;; esac; } +ORDER='{"critical":0,"high":1,"medium":2,"low":3,"unknown":4}' + +# A class marker printed before a finding line (mutated/expired resurface loudly). +class_prefix() { # _class → printed prefix (empty for plain active) + case "$1" in + mutated) printf '%s🔁 MUTATED%s ' "$R" "$O" ;; + expired) printf '%s⌛ EXPIRED-PIN%s ' "$Y" "$O" ;; + *) : ;; + esac +} + # render_lenses — print a section per lens-*.json in the run dir, and update -# OVERALL to the worst status seen. Lenses are honey's additional scanners -# (e.g. skillspector for agent skills); absent ones simply don't appear. +# OVERALL to the worst status seen (AFTER suppression). Lenses are honey's +# additional scanners; absent ones simply don't appear. OVERALL="$STATUS" render_lenses() { local lj name lstatus ltotal lnote @@ -59,33 +82,54 @@ render_lenses() { lstatus="$(jq -r '.status' "$lj" 2>/dev/null)" ltotal="$(jq -r '.findings_total' "$lj" 2>/dev/null)" lnote="$(jq -r '.note // empty' "$lj" 2>/dev/null)" - [ "$(rank "$lstatus")" -gt "$(rank "$OVERALL")" ] && OVERALL="$lstatus" echo case "$lstatus" in skipped) echo "${D}— lens ${name}: skipped${O} (${lnote})"; continue ;; clean) echo "${G}✓ lens ${name}: clean${O} (${lnote})"; continue ;; - scan_error) echo "${R}✗ lens ${name}: scan error${O} (${lnote})"; continue ;; - incomplete) echo "${Y}⚠ lens ${name}: incomplete${O} (${lnote})"; continue ;; + scan_error) echo "${R}✗ lens ${name}: scan error${O} (${lnote})" + [ "$(rank scan_error)" -gt "$(rank "$OVERALL")" ] && OVERALL="scan_error"; continue ;; + incomplete) echo "${Y}⚠ lens ${name}: incomplete${O} (${lnote})" + [ "$(rank incomplete)" -gt "$(rank "$OVERALL")" ] && OVERALL="incomplete"; continue ;; esac - # exposed → list its findings worst-severity first. - local by; by="$(jq -r '.findings_by_severity | to_entries | map("\(.value) \(.key)") | join(", ")' "$lj" 2>/dev/null)" - echo "${R}🚨 lens ${name}: ${ltotal} finding(s)${O} [$by] ${D}(${lnote})${O}" - jq -rs --argjson ord "$ORDER" '.[0] | (.findings // []) - | sort_by($ord[.severity] // 9)[] - | [.severity,.title,.location,.detail,.ref] | @tsv' "$lj" 2>/dev/null \ - | while IFS=$'\t' read -r SEV TITLE LOC DET REF; do + + # exposed → classify against the baseline, then render only the findings + # that still contribute (active + mutated + expired). Suppressed ones are + # counted and summarized, not listed. + local cls s m e a + cls="$(honey_classify_lens "$name" "$lj")" + s="$(printf '%s\n' "$cls" | jq -r 'select(._class=="suppressed")|1' 2>/dev/null | grep -c .)" + m="$(printf '%s\n' "$cls" | jq -r 'select(._class=="mutated")|1' 2>/dev/null | grep -c .)" + e="$(printf '%s\n' "$cls" | jq -r 'select(._class=="expired")|1' 2>/dev/null | grep -c .)" + a="$(printf '%s\n' "$cls" | jq -r 'select(._class!="suppressed")|1' 2>/dev/null | grep -c .)" + SUP=$((SUP+s)); MUT=$((MUT+m)); EXP=$((EXP+e)) + + if [ "$a" -eq 0 ]; then + # Every finding suppressed: this lens no longer contributes to the verdict. + echo "${G}✓ lens ${name}: clean${O} — all ${ltotal} finding(s) suppressed by baseline. ${D}(${lnote})${O}" + continue + fi + [ "$(rank exposed)" -gt "$(rank "$OVERALL")" ] && OVERALL="exposed" + + local by supnote="" + by="$(printf '%s\n' "$cls" | jq -rs 'map(select(._class!="suppressed")) | group_by(.severity) + | map("\(length) \(.[0].severity)") | join(", ")' 2>/dev/null)" + [ "$s" -gt 0 ] && supnote=" ${D}(+${s} suppressed)${O}" + echo "${R}🚨 lens ${name}: ${a} finding(s)${O} [$by]${supnote} ${D}(${lnote})${O}" + printf '%s\n' "$cls" | jq -rs --argjson ord "$ORDER" ' + map(select(._class!="suppressed")) | sort_by($ord[.severity] // 9)[] + | [.severity,.title,.location,.detail,.ref,._class] | @tsv' 2>/dev/null \ + | while IFS=$'\t' read -r SEV TITLE LOC DET REF CLASS; do case "$SEV" in critical|high) C="$R";; medium) C="$Y";; *) C="$D";; esac SEV_UC="$(printf '%s' "$SEV" | tr '[:lower:]' '[:upper:]')" REFSUFFIX=""; [ -n "$REF" ] && REFSUFFIX=" ${D}(${REF})${O}" - echo " ${C}● ${SEV_UC}${O} ${B}${TITLE}${O}${REFSUFFIX}" + echo " $(class_prefix "$CLASS")${C}● ${SEV_UC}${O} ${B}${TITLE}${O}${REFSUFFIX}" echo " where : $LOC" [ -n "$DET" ] && echo " detail: $DET" + [ "$CLASS" = "mutated" ] && echo " ${R}note : content changed since this was pinned reviewed-benign — re-review before re-pinning.${O}" done done } -ORDER='{"critical":0,"high":1,"medium":2,"low":3,"unknown":4}' - echo "${B}honey scan report${O} ${D}($WHEN)${O}" echo "host: $HOST scanned: $ROOT files: $FILES scanner: $VER" echo @@ -102,38 +146,69 @@ case "$STATUS" in echo "Diagnostics:"; tail -n 5 "$RUN_DIR/diagnostics.ndjson" 2>/dev/null | sed 's/^/ /' echo "Full log: $RUN_DIR/update.log" ;; exposed) - BY_SEV="$(j '.findings_by_severity | to_entries | map("\(.value) \(.key)") | join(", ")')" - echo "${R}🚨 bumblebee: EXPOSED${O} — $TOTAL match(es): $BY_SEV" - echo - jq -rs --argjson ord "$ORDER" ' - sort_by($ord[.severity] // 9)[] | - [.severity,.package_name,.version,.ecosystem,.catalog_name,.source_file,.confidence,.catalog_id] - | @tsv' "$FINDINGS" 2>/dev/null | while IFS=$'\t' read -r SEV PKG VER_ ECO CAT SRC CONF CID; do - case "$SEV" in critical|high) C="$R";; medium) C="$Y";; *) C="$D";; esac - SEV_UC="$(printf '%s' "$SEV" | tr '[:lower:]' '[:upper:]')" # portable: macOS bash 3.2, no ${x^^} - echo " ${C}● ${SEV_UC}${O} ${B}$PKG${O} $VER_ ${D}($ECO)${O}" - echo " campaign : $CAT" - echo " where : $SRC" - case "$CONF" in - high) echo " confidence: high — exact installed version present";; - medium) echo " confidence: medium — identity reliable, version/source partial";; - low) echo " confidence: low — config/spec reference, not proof of an installed build";; - *) echo " confidence: $CONF";; - esac - echo " fix : $(remediation "$ECO" "$PKG" "$VER_")" - echo " source : catalog entry $CID in $CATDIR" - done ;; + # Classify bumblebee matches against the baseline (pinning a known- + # compromised match is opt-in and rare, but honored uniformly). + BCLS="$(honey_classify_bumblebee "$FINDINGS")" + bs="$(printf '%s\n' "$BCLS" | jq -r 'select(._class=="suppressed")|1' 2>/dev/null | grep -c .)" + bm="$(printf '%s\n' "$BCLS" | jq -r 'select(._class=="mutated")|1' 2>/dev/null | grep -c .)" + be="$(printf '%s\n' "$BCLS" | jq -r 'select(._class=="expired")|1' 2>/dev/null | grep -c .)" + ba="$(printf '%s\n' "$BCLS" | jq -r 'select(._class!="suppressed")|1' 2>/dev/null | grep -c .)" + SUP=$((SUP+bs)); MUT=$((MUT+bm)); EXP=$((EXP+be)) + if [ "$ba" -eq 0 ]; then + echo "${G}✓ bumblebee: CLEAN${O} — all $TOTAL match(es) suppressed by baseline (review with ./honey-baseline.sh)." + else + [ "$(rank exposed)" -gt "$(rank "$OVERALL")" ] && OVERALL="exposed" + BY_SEV="$(printf '%s\n' "$BCLS" | jq -rs 'map(select(._class!="suppressed")) | group_by(.severity) | map("\(length) \(.[0].severity)") | join(", ")')" + SUPN=""; [ "$bs" -gt 0 ] && SUPN=" ${D}(+${bs} suppressed)${O}" + echo "${R}🚨 bumblebee: EXPOSED${O} — $ba match(es): $BY_SEV${SUPN}" + echo + printf '%s\n' "$BCLS" | jq -rs --argjson ord "$ORDER" ' + map(select(._class!="suppressed")) | sort_by($ord[.severity] // 9)[] | + [.severity,.package_name,.version,.ecosystem,.catalog_name,.source_file,.confidence,.catalog_id,._class] + | @tsv' 2>/dev/null | while IFS=$'\t' read -r SEV PKG VER_ ECO CAT SRC CONF CID CLASS; do + case "$SEV" in critical|high) C="$R";; medium) C="$Y";; *) C="$D";; esac + SEV_UC="$(printf '%s' "$SEV" | tr '[:lower:]' '[:upper:]')" # portable: macOS bash 3.2, no ${x^^} + echo " $(class_prefix "$CLASS")${C}● ${SEV_UC}${O} ${B}$PKG${O} $VER_ ${D}($ECO)${O}" + echo " campaign : $CAT" + echo " where : $SRC" + case "$CONF" in + high) echo " confidence: high — exact installed version present";; + medium) echo " confidence: medium — identity reliable, version/source partial";; + low) echo " confidence: low — config/spec reference, not proof of an installed build";; + *) echo " confidence: $CONF";; + esac + echo " fix : $(remediation "$ECO" "$PKG" "$VER_")" + echo " source : catalog entry $CID in $CATDIR" + [ "$CLASS" = "mutated" ] && echo " ${R}note : content changed since this was pinned reviewed-benign — re-review before re-pinning.${O}" + done + fi ;; esac # --- additional lenses ------------------------------------------------------ render_lenses +# --- suppression summary ---------------------------------------------------- +if [ $((SUP+MUT+EXP)) -gt 0 ]; then + echo + echo "${D}baseline: ${SUP} suppressed, ${MUT} mutated, ${EXP} expired — honey.baseline.json. Review: ./honey-baseline.sh status${O}" + [ "$MUT" -gt 0 ] && echo "${R}⚠ ${MUT} MUTATED: a file pinned as reviewed-benign has CHANGED. Treat as a possible rug pull and re-review.${O}" +fi + # --- overall verdict -------------------------------------------------------- +# Suffix tallies so a suppressed run can never read as a bare all-clear. +SUFFIX="" +if [ $((SUP+MUT+EXP)) -gt 0 ]; then + SUFFIX=" (${SUP} suppressed" + [ "$MUT" -gt 0 ] && SUFFIX="${SUFFIX}, ${MUT} mutated" + [ "$EXP" -gt 0 ] && SUFFIX="${SUFFIX}, ${EXP} expired" + SUFFIX="${SUFFIX})" +fi + echo if [ "$OVERALL" = "clean" ]; then - echo "${G}OVERALL: CLEAN${O} across bumblebee and all active lenses." + echo "${G}OVERALL: CLEAN${O}${SUFFIX} across bumblebee and all active lenses." exit 0 fi -echo "${B}OVERALL: $(printf '%s' "$OVERALL" | tr '[:lower:]' '[:upper:]')${O} — address critical/high + high-confidence items first." +echo "${B}OVERALL: $(printf '%s' "$OVERALL" | tr '[:lower:]' '[:upper:]')${O}${SUFFIX} — address critical/high + high-confidence items first." echo "Verify each fix manually; honey only reports, it never changes your system. Raw records under: $RUN_DIR" exit 1 diff --git a/routine-prompt.md b/routine-prompt.md index 73fcab1..efe09f5 100644 --- a/routine-prompt.md +++ b/routine-prompt.md @@ -51,6 +51,20 @@ CRITICAL — do not produce a false all-clear: prints `OVERALL: EXPOSED` (or INCOMPLETE / SCAN_ERROR), the run is NOT clear — even if bumblebee itself was clean. +SUPPRESSION BASELINE — the OVERALL line may carry a tally, e.g. +`OVERALL: CLEAN (12 suppressed)` or `OVERALL: EXPOSED — … (12 suppressed, 2 mutated)`: + • `suppressed` = findings the user reviewed and pinned as benign in + honey.baseline.json; they are intentionally dropped from the verdict. Do NOT + re-alarm on them — mention the count in passing, nothing more. + • `mutated` = a file that was pinned reviewed-benign has CHANGED since it was + pinned (marked 🔁 MUTATED in the report). Treat this as HIGH signal — a + possible rug pull / tampered dependency — surface it prominently and never + fold it into "all clear". A run with any `mutated` is never CLEAN. + • `expired` = a pin passed its expiry and resurfaced; treat as a normal active + finding due for re-review. + • `CLEAN (N suppressed)` is still all-clear — but say "all clear (N previously + reviewed findings suppressed)", not a bare "all clear". + Treat report.sh's output as the factual baseline; do not contradict it or invent findings beyond it. For extra detail read HONEY_DIR/latest/lens-*.json (each lens's normalized findings: severity, title, location, detail, ref) and, diff --git a/triage-guide.md b/triage-guide.md index 2f6fa47..8b9e850 100644 --- a/triage-guide.md +++ b/triage-guide.md @@ -21,6 +21,14 @@ exactly matches a known-compromised entry in a threat-intelligence catalog. agent skills); the OVERALL verdict is the worst across all of them. Surface every scanner's findings, not just bumblebee's. + The OVERALL line may carry a suppression tally, e.g. `CLEAN (12 suppressed)` + or `EXPOSED — … (12 suppressed, 2 mutated)`. `suppressed` = findings the user + pinned as reviewed-benign in `honey.baseline.json` (dropped from the verdict — + don't re-alarm). `mutated` (🔁 in the report) = a pinned file CHANGED since + review — treat as high signal (possible rug pull), surface it, and never call + such a run clean. Manage pins with `./honey-baseline.sh` (see + [`docs/BASELINE.plan.md`](docs/BASELINE.plan.md)). + 2. For detail beyond the report, read the run dir: - `manifest.json` — bumblebee verdict, counts, metadata. - `findings.ndjson` — bumblebee findings, one per line. diff --git a/win/TESTING.md b/win/TESTING.md index c273d7d..f5bd890 100644 --- a/win/TESTING.md +++ b/win/TESTING.md @@ -2,7 +2,8 @@ The PowerShell variant under `win/` was authored and tested on macOS pwsh 7.5.4 (the orchestration, all three lens normalizers, run-scan, daily-cycle, report, -setup, doctor — all verified behavior-identical to the bash version). What +setup, doctor, and the suppression baseline classifier + CLI — all verified +behavior-identical to the bash version). What **couldn't** be tested off-Windows is marked **[W]** below: Windows toast notifications, Task Scheduler, real `C:\` paths, and the real scanners on Windows. That's what this guide is for. @@ -88,6 +89,35 @@ pwsh -File win\report.ps1 only if `OVERALL: CLEAN`. **Confirm the false-all-clear guard:** if bumblebee is clean but a lens found something, the OVERALL must read EXPOSED (not "all clear"). +## 4b. honey-baseline.ps1 — suppression baseline (pin-and-diff) + +The classifier, all five subcommands, and the report/daily-cycle integration +were verified on macOS pwsh 7.5.4 (behavior-identical to the bash side, incl. +byte-identical file hashes). What's worth re-confirming on Windows is that real +`C:\` locations pin, suppress, and diff correctly. + +```powershell +# Pick a lens finding from the latest run and pin it: +pwsh -File win\honey-baseline.ps1 status # tally: active/suppressed/mutated/expired +pwsh -File win\honey-baseline.ps1 add -Scanner skillspector -Location references\ ` + -Reason "first-party marketplace docs" -Expires 90 +pwsh -File win\honey-baseline.ps1 list # shows the new pin(s) +pwsh -File win\report.ps1 # those findings now show as suppressed; OVERALL carries "(N suppressed)" +``` + +**Expected / confirm:** +- `add` writes `honey.baseline.json` at the repo root with `~`-relative + `location` values (home collapsed to `~`, **not** an absolute `C:\Users\…`). +- After `add`, `report.ps1` drops the pinned findings from the verdict and the + OVERALL line reads e.g. `EXPOSED (N suppressed)` — never a bare all-clear. +- **[W] the mutation tripwire:** edit one pinned file (append a space to a + `references\*.md` that was pinned), then re-run `report.ps1` — that finding + must resurface tagged `[MUTATED]` and OVERALL must show `… mutated`. +- `remove` / `prune` behave as `list` reports; `add` is idempotent (re-running + the same `add` doesn't duplicate entries). +- A bumblebee match is excluded by `-All`; pinning one needs explicit + `-Scanner bumblebee`. + ## 5. [W] notify-cycle.ps1 — scan + Windows toast ```powershell diff --git a/win/daily-cycle.ps1 b/win/daily-cycle.ps1 index f5732f7..2bf5835 100644 --- a/win/daily-cycle.ps1 +++ b/win/daily-cycle.ps1 @@ -14,6 +14,7 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' Import-Module (Join-Path $PSScriptRoot 'lib' 'Honey.psm1') -Force +Import-Module (Join-Path $PSScriptRoot 'lib' 'Baseline.psm1') -Force Import-HoneyConfig $honeyRoot = Get-HoneyRoot @@ -48,8 +49,9 @@ $bbStatus = [string]$manifest.status $bbTotal = $manifest.findings_total Log "manifest status=$bbStatus findings=$bbTotal run_dir=$runDir" -# 2. Lenses. Worst-wins; a lens that RAN but wrote no valid verdict -> scan_error. -$overall = $bbStatus +# 2. Lenses. Run each; the effective verdict is computed afterward through the +# suppression baseline. $crash tracks only lenses that ran but wrote no verdict. +$crash = 'clean' $lensDir = Join-Path $PSScriptRoot 'lenses' if (Test-Path $lensDir) { foreach ($lens in Get-ChildItem -Path $lensDir -Filter '*.ps1' | Sort-Object Name) { @@ -61,17 +63,24 @@ if (Test-Path $lensDir) { try { $null = Get-Content -LiteralPath $ljPath -Raw | ConvertFrom-Json; $ok = $true } catch { $ok = $false } } if (-not $ok) { + # A lens that RAN but wrote no verdict is a genuine crash; the + # baseline (which reads written JSON) can't see it, so track it here. Log "lens ${lname}: CRASHED (no valid verdict) - escalating to scan_error" - $overall = Resolve-WorseStatus $overall 'scan_error' + $crash = Resolve-WorseStatus $crash 'scan_error' continue } $lj = Get-Content -LiteralPath $ljPath -Raw | ConvertFrom-Json $lstatus = [string]$lj.status Log "lens ${lname}: status=$lstatus findings=$($lj.findings_total)" - if ($lstatus -eq 'skipped') { continue } - $overall = Resolve-WorseStatus $overall $lstatus } } -Log "=== cycle end (bumblebee=$bbStatus overall=$overall) ===" +# Effective verdict AFTER the suppression baseline, via the same classifier +# report.ps1 uses (one source of truth). Suppressed findings drop out; MUTATED +# pins resurface; incomplete/scan_error are never downgraded. Then worst-wins +# the crashed-lens escalation in. +$eff = Get-HoneyEffectiveOverall -RunDir $runDir +$overall = Resolve-WorseStatus $eff.status $crash + +Log "=== cycle end (bumblebee=$bbStatus overall=$overall suppressed=$($eff.suppressed) mutated=$($eff.mutated) expired=$($eff.expired)) ===" if ($overall -eq 'clean') { exit 0 } else { exit 1 } diff --git a/win/doctor.ps1 b/win/doctor.ps1 index 7c5f091..b443979 100644 --- a/win/doctor.ps1 +++ b/win/doctor.ps1 @@ -8,6 +8,7 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' Import-Module (Join-Path $PSScriptRoot 'lib' 'Honey.psm1') -Force +Import-Module (Join-Path $PSScriptRoot 'lib' 'Baseline.psm1') -Force Import-HoneyConfig $home_ = Get-HoneyHome @@ -75,6 +76,20 @@ foreach ($t in $lensTools.Keys) { else { Write-HoneyConsole " [ ] lens $t inactive (optional)"; Hint $lensTools[$t] } } +# Suppression baseline (informational; never affects pass/fail). +Write-HoneyConsole "" +Write-HoneyConsole "suppression baseline:" +$bfile = Get-HoneyBaselineFile +if (Test-Path -LiteralPath $bfile) { + $entries = @(Get-HoneyBaselineEntry) + $today = Get-HoneyToday + $expired = @($entries | Where-Object { $_.expires -ne 'never' -and [string]$_.expires -lt $today }).Count + Ok "baseline present ($($entries.Count) pin(s)) - $bfile" + if ($expired -gt 0) { Hint "$expired pin(s) expired; review with honey-baseline.ps1 list -Expired, then honey-baseline.ps1 prune" } +} else { + Ok "no baseline yet (all findings active) - create pins with honey-baseline.ps1 add" +} + Write-HoneyConsole "" if ($fails -eq 0) { Write-HoneyConsole "All checks passed - honey is ready. Run: pwsh -File win\daily-cycle.ps1" diff --git a/win/honey-baseline.ps1 b/win/honey-baseline.ps1 new file mode 100644 index 0000000..5844601 --- /dev/null +++ b/win/honey-baseline.ps1 @@ -0,0 +1,206 @@ +<# +.SYNOPSIS + honey (Windows): manage the pin-and-diff suppression baseline. PowerShell port + of honey-baseline.sh. + +.DESCRIPTION + A baseline entry acknowledges a reviewed finding by (scanner, rule, location, + occurrence index) AND a sha256 of the referenced content. A matching finding + whose content still hashes the same is SUPPRESSED (dropped from the verdict, + still shown). If the content changes, the finding resurfaces as MUTATED. + See docs/BASELINE.plan.md. + + Commands: + status [RunDir] dry-run tally (suppressed/mutated/expired/active) + add [RunDir] -Reason S [-Expires DAYS|never] [-AddedBy NAME] + list [-Expired | -Active] show entries (flags expired) + remove drop matching entries + prune remove expired entries + + (combinable, AND): -All (excludes bumblebee) | -Scanner NAME | + -Severity SEV | -Rule TEXT | -Location SUBSTR + + This tool NEVER changes system state; it only edits honey.baseline.json, + which you review and commit. + +.EXAMPLE + pwsh -File win\honey-baseline.ps1 add -Scanner skillspector -Location references\ -Reason "first-party docs" -Expires 90 +#> +[CmdletBinding()] +param( + [Parameter(Position = 0)][ValidateSet('status','add','list','remove','prune','help')][string]$Command = 'help', + [Parameter(Position = 1)][string]$RunDir, + [switch]$All, + [string]$Scanner, + [string]$Severity, + [string]$Rule, + [string]$Location, + [string]$Reason, + [string]$Expires = '90', + [string]$AddedBy = $env:USERNAME, + [switch]$Expired, + [switch]$Active +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot 'lib' 'Honey.psm1') -Force +Import-Module (Join-Path $PSScriptRoot 'lib' 'Baseline.psm1') -Force +Import-HoneyConfig + +function Say { param($m) Write-Information $m -InformationAction Continue } +function Die { param($m) Write-Error "honey-baseline: $m"; exit 2 } + +$baselineFile = Get-HoneyBaselineFile + +function Resolve-HoneyRun { + param([string]$Dir) + if ($Dir -and (Test-Path (Join-Path $Dir 'manifest.json'))) { return $Dir } + $rd = Get-HoneyLatest -HoneyRoot (Get-HoneyRoot) + if (-not $rd) { Die "no run dir (pass one, or run a scan so latest.txt exists)" } + return $rd +} + +function Initialize-HoneyBaseline { + if (-not (Test-Path -LiteralPath $baselineFile)) { + '{"version":1,"entries":[]}' | Set-Content -LiteralPath $baselineFile -Encoding utf8 + } +} + +function Save-HoneyBaseline { + param($Entries) + $doc = [ordered]@{ version = 1; entries = @($Entries) } + ($doc | ConvertTo-Json -Depth 12) | Set-Content -LiteralPath $baselineFile -Encoding utf8 +} + +function Get-HoneyEntryKey { + param($e) + # New entries are hashtables; existing (from JSON) are PSCustomObjects. Read + # `index` uniformly from both so identity keys match across a re-add. + $ix = 0 + if ($e -is [System.Collections.IDictionary]) { + if ($e.Contains('index') -and $null -ne $e['index']) { $ix = $e['index'] } + } elseif ($e.PSObject.Properties.Name -contains 'index' -and $null -ne $e.index) { + $ix = $e.index + } + "$($e.scanner)$($e.rule)$($e.location)$ix" +} + +# Any selector given? (guards against an accidental match-all.) Referencing the +# filter switches/strings here also marks them "used" for the analyzer. +$hasFilter = ($All -or $Scanner -or $Severity -or $Rule -or $Location) + +switch ($Command) { + +'status' { + $run = Resolve-HoneyRun -Dir $RunDir + $entries = @(Get-HoneyBaselineEntry) + Say "baseline status (run: $run)" + Say "baseline: $baselineFile ($($entries.Count) entr$(if ($entries.Count -eq 1) {'y'} else {'ies'}))" + # NB: not $all — it would alias the [switch]$All parameter (case-insensitive). + $classified = @(Get-HoneyClassifiedRun -RunDir $run) + $s = @($classified | Where-Object { $_._class -eq 'suppressed' }).Count + $m = @($classified | Where-Object { $_._class -eq 'mutated' }).Count + $e = @($classified | Where-Object { $_._class -eq 'expired' }).Count + $a = @($classified | Where-Object { $_._class -eq 'active' }).Count + Say " active: $a (contribute to the verdict)" + Say " suppressed: $s (pinned & unchanged - hidden from the verdict)" + Say " mutated: $m (pinned content CHANGED - resurfaced, review now)" + Say " expired: $e (pin past its expiry - resurfaced)" + if ($m -gt 0) { Say "[!!] $m mutated finding(s): a reviewed file changed since it was pinned." } +} + +'list' { + $entries = @(Get-HoneyBaselineEntry) + $today = Get-HoneyToday + if ($Expired) { $entries = @($entries | Where-Object { $_.expires -ne 'never' -and [string]$_.expires -lt $today }) } + elseif ($Active) { $entries = @($entries | Where-Object { $_.expires -eq 'never' -or [string]$_.expires -ge $today }) } + foreach ($e in $entries) { + $flag = if ($e.expires -ne 'never' -and [string]$e.expires -lt $today) { 'EXPIRED ' } else { ' ' } + Say "$flag[$($e.scanner)] $($e.rule)" + Say " $($e.location) (index $(Get-HoneyEntryIndex $e))" + Say " reason: $($e.reason) added: $($e.added) expires: $($e.expires)" + } + $n = @(Get-HoneyBaselineEntry).Count + Say "$n entr$(if ($n -eq 1) {'y'} else {'ies'}) in $baselineFile" +} + +'prune' { + Initialize-HoneyBaseline + $today = Get-HoneyToday + $before = @(Get-HoneyBaselineEntry) + $kept = @($before | Where-Object { $_.expires -eq 'never' -or [string]$_.expires -ge $today }) + Save-HoneyBaseline $kept + Say "pruned $($before.Count - $kept.Count) expired entr$(if (($before.Count - $kept.Count) -eq 1) {'y'} else {'ies'}); $($kept.Count) remain." +} + +'add' { + $run = Resolve-HoneyRun -Dir $RunDir + if (-not $Reason) { Die "add: -Reason is required (why is this finding benign?)" } + if (-not $hasFilter) { Die "add: refusing to pin with no selector - pass -All or a -Scanner/-Severity/-Rule/-Location filter" } + if ($Expires -eq 'never') { $expiresDate = 'never' } + elseif ($Expires -match '^\d+$') { $expiresDate = (Get-Date).ToUniversalTime().AddDays([int]$Expires).ToString('yyyy-MM-dd') } + else { Die "add: -Expires must be a number of days or 'never'" } + $added = Get-HoneyToday + Initialize-HoneyBaseline + + # Filter classified findings inline (references the filter params in-body). + # -All excludes bumblebee: pinning a known-compromised match must be explicit. + $sel = @(Get-HoneyClassifiedRun -RunDir $run | Where-Object { + (-not ($All -and $_._scanner -eq 'bumblebee')) -and + (-not $Scanner -or $_._scanner -eq $Scanner) -and + (-not $Severity -or [string]$_.severity -eq $Severity) -and + (-not $Rule -or ([string]$_.rule).Contains($Rule)) -and + (-not $Location -or ([string]$_._loc).Contains($Location)) + }) + if ($sel.Count -eq 0) { Say "add: no findings matched that filter in $run"; break } + + $newEntries = foreach ($f in $sel) { + $loc = [string]$f._loc + $abs = ConvertFrom-HoneyTilde (Split-HoneyLocationFile $loc) + if (Test-Path -LiteralPath $abs -PathType Leaf) { $chash = Get-HoneyFileHash $abs; $csrc = 'file' } + else { $chash = Get-HoneyStringHash (Get-HoneyFindingCanonical $f); $csrc = 'finding' } + [ordered]@{ + scanner = [string]$f._scanner; rule = [string]$f.rule; location = $loc; index = [int]$f._index + content_hash = $chash; content_source = $csrc + severity = $(if ($f.PSObject.Properties.Name -contains 'severity' -and $f.severity) { [string]$f.severity } else { 'unknown' }) + reason = $Reason; added = $added; expires = $expiresDate; added_by = $AddedBy + } + } + $newEntries = @($newEntries) + + # Merge: new entries replace any existing pin with the same identity key. + $newKeys = @($newEntries | ForEach-Object { Get-HoneyEntryKey $_ }) + $kept = @(Get-HoneyBaselineEntry | Where-Object { $newKeys -notcontains (Get-HoneyEntryKey $_) }) + Save-HoneyBaseline (@($kept) + @($newEntries)) + + $bcount = @($newEntries | Where-Object { $_.scanner -eq 'bumblebee' }).Count + Say "pinned $($sel.Count) finding(s) into $baselineFile (expires: $expiresDate)." + if ($bcount -gt 0) { Say "[!!] $bcount of these are bumblebee (known-compromised) matches - make sure that is intended." } + Say "Review the diff and commit honey.baseline.json." +} + +'remove' { + Initialize-HoneyBaseline + if (-not $hasFilter) { Die "remove: pass a selector (-All/-Scanner/-Severity/-Rule/-Location)" } + $before = @(Get-HoneyBaselineEntry) + $kept = @($before | Where-Object { + -not ( + (-not ($All -and $_.scanner -eq 'bumblebee')) -and + (-not $Scanner -or $_.scanner -eq $Scanner) -and + (-not $Severity -or [string]$_.severity -eq $Severity) -and + (-not $Rule -or ([string]$_.rule).Contains($Rule)) -and + (-not $Location -or ([string]$_.location).Contains($Location)) + ) + }) + Save-HoneyBaseline $kept + Say "removed $($before.Count - $kept.Count) entr$(if (($before.Count - $kept.Count) -eq 1) {'y'} else {'ies'}); $($kept.Count) remain." +} + +default { + Say "honey-baseline.ps1 [-RunDir DIR] [filters] [-Reason STR] [-Expires DAYS|never]" + Say "Filters: -All -Scanner NAME -Severity SEV -Rule TEXT -Location SUBSTR (list also: -Expired | -Active)" + Say "Pins reviewed-benign findings by content hash; a changed file resurfaces as MUTATED. See docs/BASELINE.plan.md." +} + +} diff --git a/win/lib/Baseline.psm1 b/win/lib/Baseline.psm1 new file mode 100644 index 0000000..48dbecc --- /dev/null +++ b/win/lib/Baseline.psm1 @@ -0,0 +1,277 @@ +<# +.SYNOPSIS + honey (Windows): pin-and-diff suppression baseline — PowerShell mirror of + lib/baseline.sh. Imported by report.ps1, daily-cycle.ps1, honey-baseline.ps1 + so all three agree on one classification. + +.NOTES + Same model as bash: a baseline entry pins a finding by (scanner, rule, + ~-relative location, occurrence index) AND a sha256 of the referenced file's + content. A matching finding whose file hash still matches is SUPPRESSED + (dropped from the verdict, still reported); a pinned file whose content + CHANGED resurfaces as MUTATED (the rug-pull tripwire); an expired pin + resurfaces as normal. See docs/BASELINE.plan.md. + + Content hashes are lowercase "sha256:" to match the bash format. The + baseline file is per-machine (paths and line-endings differ across OSes), so + it is not meant to be shared between the bash and Windows variants. +#> + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +# Import Honey WITHOUT -Force: a forced re-import here would evict Honey's +# commands from a caller that already imported it (report.ps1/daily-cycle.ps1). +Import-Module (Join-Path $PSScriptRoot 'Honey.psm1') + +# --- config ------------------------------------------------------------------ + +function Get-HoneyBaselineFile { + if ($env:HONEY_BASELINE) { return $env:HONEY_BASELINE } + return (Join-Path (Get-HoneyRoot) 'honey.baseline.json') +} + +# Entries array (empty when the file is absent/malformed). +function Get-HoneyBaselineEntry { + $f = Get-HoneyBaselineFile + if (-not (Test-Path -LiteralPath $f)) { return @() } + try { + $doc = Get-Content -LiteralPath $f -Raw | ConvertFrom-Json + if ($doc.PSObject.Properties.Name -contains 'entries' -and $doc.entries) { return @($doc.entries) } + return @() + } catch { return @() } +} + +# --- path helpers ------------------------------------------------------------ + +# ABS -> ~-relative (home prefix collapsed to ~). Mirrors the jq tildify. +function ConvertTo-HoneyTilde { + param([string]$Path) + $home_ = Get-HoneyHome + if ($Path -eq $home_) { return '~' } + if ($Path.StartsWith($home_ + [IO.Path]::DirectorySeparatorChar) -or $Path.StartsWith($home_ + '/')) { + return '~/' + $Path.Substring($home_.Length + 1) + } + return $Path +} + +# ~-relative -> ABS. +function ConvertFrom-HoneyTilde { + param([string]$Path) + $home_ = Get-HoneyHome + if ($Path -eq '~') { return $home_ } + if ($Path.StartsWith('~/') -or $Path.StartsWith('~\')) { + return Join-Path $home_ $Path.Substring(2) + } + return $Path +} + +# Drop a trailing : from a "file:line" location, keeping a Windows drive +# colon intact. +function Split-HoneyLocationFile { + param([string]$Location) + if ($Location -match '^(.*):(\d+)$') { return $Matches[1] } + return $Location +} + +# --- hashing ----------------------------------------------------------------- + +# sha256 of a file's raw bytes -> "sha256:" ('' on failure). +function Get-HoneyFileHash { + param([string]$Path) + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return '' } + try { return 'sha256:' + (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLower() } + catch { return '' } +} + +# sha256 of a UTF-8 string -> "sha256:". Used for the finding-JSON +# fallback when a location is not an on-disk file. +function Get-HoneyStringHash { + param([string]$Text) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Text) + return 'sha256:' + (($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join '') + } finally { $sha.Dispose() } +} + +# Canonical finding serialization for the finding-JSON fallback: original fields +# only (drop _-prefixed), keys sorted, compact. Deterministic within this port. +function Get-HoneyFindingCanonical { + param($Finding) + $o = [ordered]@{} + $Finding.PSObject.Properties | + Where-Object { -not $_.Name.StartsWith('_') } | + Sort-Object Name | + ForEach-Object { $o[$_.Name] = $_.Value } + return ($o | ConvertTo-Json -Depth 12 -Compress) +} + +function Get-HoneyToday { (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd') } + +# --- classification ---------------------------------------------------------- + +# Classify an array of finding objects (each with .rule and ._rawloc, plus the +# original fields). Returns the findings with _loc/_index/_class/_reason added. +function Get-HoneyClassified { + param([string]$Scanner, [object[]]$Findings) + $entries = Get-HoneyBaselineEntry + $today = Get-HoneyToday + $seen = @{} + $out = New-Object System.Collections.ArrayList + foreach ($f in @($Findings)) { + $rawloc = if ($f.PSObject.Properties.Name -contains '_rawloc') { [string]$f._rawloc } else { '' } + $loc = ConvertTo-HoneyTilde $rawloc + $rule = if ($f.PSObject.Properties.Name -contains 'rule') { [string]$f.rule } else { '' } + $k = "$rule $loc" + $idx = if ($seen.ContainsKey($k)) { $seen[$k] + 1 } else { 0 } + $seen[$k] = $idx + + $f | Add-Member -NotePropertyName '_loc' -NotePropertyValue $loc -Force + $f | Add-Member -NotePropertyName '_index' -NotePropertyValue $idx -Force + + $entry = $entries | Where-Object { + $_.scanner -eq $Scanner -and $_.rule -eq $rule -and $_.location -eq $loc -and + (([int](Get-HoneyEntryIndex $_)) -eq $idx) + } | Select-Object -First 1 + + if (-not $entry) { + $f | Add-Member -NotePropertyName '_class' -NotePropertyValue 'active' -Force + $f | Add-Member -NotePropertyName '_reason' -NotePropertyValue '' -Force + [void]$out.Add($f); continue + } + + $csource = if ($entry.PSObject.Properties.Name -contains 'content_source' -and $entry.content_source) { [string]$entry.content_source } else { 'file' } + if ($csource -eq 'finding') { + $curhash = Get-HoneyStringHash (Get-HoneyFindingCanonical $f) + } else { + $abs = ConvertFrom-HoneyTilde (Split-HoneyLocationFile $loc) + $curhash = Get-HoneyFileHash $abs + } + $expires = if ($entry.PSObject.Properties.Name -contains 'expires' -and $entry.expires) { [string]$entry.expires } else { 'never' } + $reason = if ($entry.PSObject.Properties.Name -contains 'reason' -and $entry.reason) { [string]$entry.reason } else { '' } + $cmp = if ($entry.PSObject.Properties.Name -contains 'content_hash') { [string]$entry.content_hash } else { '' } + + $class = 'mutated' + if ($curhash -and $curhash -eq $cmp) { + if ($expires -ne 'never' -and $expires -lt $today) { $class = 'expired' } else { $class = 'suppressed' } + } + $f | Add-Member -NotePropertyName '_class' -NotePropertyValue $class -Force + $f | Add-Member -NotePropertyName '_reason' -NotePropertyValue $reason -Force + [void]$out.Add($f) + } + return $out.ToArray() +} + +# An entry's index (defaults to 0 when absent). +function Get-HoneyEntryIndex { + param($Entry) + if ($Entry.PSObject.Properties.Name -contains 'index' -and $null -ne $Entry.index) { return $Entry.index } + return 0 +} + +# Classify a lens's normalized findings (from lens-.json object). +function Get-HoneyClassifiedLens { + # "Lens" is a singular noun that happens to end in 's'. + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')] + param([string]$Scanner, $LensObj) + $src = @() + if ($LensObj.PSObject.Properties.Name -contains 'findings' -and $LensObj.findings) { $src = @($LensObj.findings) } + $prepped = foreach ($x in $src) { + $c = $x | Select-Object * + $c | Add-Member -NotePropertyName 'rule' -NotePropertyValue ([string]$x.title) -Force + $c | Add-Member -NotePropertyName '_rawloc' -NotePropertyValue ([string]$x.location) -Force + $c + } + return Get-HoneyClassified -Scanner $Scanner -Findings @($prepped) +} + +# Classify bumblebee findings (array of record objects from findings.ndjson). +function Get-HoneyClassifiedBumblebee { + param([object[]]$Records) + $prepped = foreach ($x in @($Records)) { + $c = $x | Select-Object * + $cid = if ($x.PSObject.Properties.Name -contains 'catalog_id') { [string]$x.catalog_id } else { '' } + $pkg = if ($x.PSObject.Properties.Name -contains 'package_name') { [string]$x.package_name } else { '' } + $src = if ($x.PSObject.Properties.Name -contains 'source_file') { [string]$x.source_file } else { '' } + $c | Add-Member -NotePropertyName 'rule' -NotePropertyValue "$cid|$pkg" -Force + $c | Add-Member -NotePropertyName '_rawloc' -NotePropertyValue $src -Force + $c + } + return Get-HoneyClassified -Scanner 'bumblebee' -Findings @($prepped) +} + +# Classify EVERY scanner in a run dir, tagging each finding with _scanner. +# Used by the management CLI (status/add/remove). +function Get-HoneyClassifiedRun { + param([string]$RunDir) + $out = New-Object System.Collections.ArrayList + $fp = Join-Path $RunDir 'findings.ndjson' + if (Test-Path -LiteralPath $fp) { + $recs = Get-Content -LiteralPath $fp | Where-Object { $_.Trim() } | ForEach-Object { $_ | ConvertFrom-Json } + foreach ($c in @(Get-HoneyClassifiedBumblebee -Records @($recs))) { + $c | Add-Member -NotePropertyName '_scanner' -NotePropertyValue 'bumblebee' -Force + [void]$out.Add($c) + } + } + Get-ChildItem -Path $RunDir -Filter 'lens-*.json' -ErrorAction SilentlyContinue | Sort-Object Name | ForEach-Object { + $lj = Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json + $name = if ($lj.PSObject.Properties.Name -contains 'lens') { [string]$lj.lens } else { '' } + if (-not $name) { return } + foreach ($c in @(Get-HoneyClassifiedLens -Scanner $name -LensObj $lj)) { + $c | Add-Member -NotePropertyName '_scanner' -NotePropertyValue $name -Force + [void]$out.Add($c) + } + } + return $out.ToArray() +} + +# --- verdict ----------------------------------------------------------------- + +# Effective worst-wins status AFTER suppression, over bumblebee + every lens. +# Returns a hashtable: @{ status; suppressed; mutated; expired }. +# Only `exposed` scanners are re-evaluated; incomplete/scan_error/skipped pass +# through untouched. +function Get-HoneyEffectiveOverall { + param([string]$RunDir) + $sup = 0; $mut = 0; $exp = 0; $overall = 'clean' + + $manifestPath = Join-Path $RunDir 'manifest.json' + if (Test-Path -LiteralPath $manifestPath) { + $m = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + $bst = if ($m.PSObject.Properties.Name -contains 'status') { [string]$m.status } else { 'unknown' } + $fp = Join-Path $RunDir 'findings.ndjson' + if ($bst -eq 'exposed' -and (Test-Path -LiteralPath $fp)) { + $recs = Get-Content -LiteralPath $fp | Where-Object { $_.Trim() } | ForEach-Object { $_ | ConvertFrom-Json } + $cls = Get-HoneyClassifiedBumblebee -Records @($recs) + $sup += @($cls | Where-Object { $_._class -eq 'suppressed' }).Count + $mut += @($cls | Where-Object { $_._class -eq 'mutated' }).Count + $exp += @($cls | Where-Object { $_._class -eq 'expired' }).Count + if (@($cls | Where-Object { $_._class -ne 'suppressed' }).Count -gt 0) { $overall = Resolve-WorseStatus $overall 'exposed' } + } else { + $overall = Resolve-WorseStatus $overall $bst + } + } + + Get-ChildItem -Path $RunDir -Filter 'lens-*.json' -ErrorAction SilentlyContinue | Sort-Object Name | ForEach-Object { + $lj = Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json + $name = if ($lj.PSObject.Properties.Name -contains 'lens') { [string]$lj.lens } else { '' } + $lst = if ($lj.PSObject.Properties.Name -contains 'status') { [string]$lj.status } else { 'unknown' } + if ($lst -eq 'exposed') { + $cls = Get-HoneyClassifiedLens -Scanner $name -LensObj $lj + $sup += @($cls | Where-Object { $_._class -eq 'suppressed' }).Count + $mut += @($cls | Where-Object { $_._class -eq 'mutated' }).Count + $exp += @($cls | Where-Object { $_._class -eq 'expired' }).Count + if (@($cls | Where-Object { $_._class -ne 'suppressed' }).Count -gt 0) { $overall = Resolve-WorseStatus $overall 'exposed' } + } else { + $overall = Resolve-WorseStatus $overall $lst + } + } + + return @{ status = $overall; suppressed = $sup; mutated = $mut; expired = $exp } +} + +Export-ModuleMember -Function Get-HoneyBaselineFile, Get-HoneyBaselineEntry, + ConvertTo-HoneyTilde, ConvertFrom-HoneyTilde, Split-HoneyLocationFile, + Get-HoneyFileHash, Get-HoneyStringHash, Get-HoneyFindingCanonical, Get-HoneyToday, + Get-HoneyClassified, Get-HoneyClassifiedLens, Get-HoneyClassifiedBumblebee, + Get-HoneyClassifiedRun, Get-HoneyEntryIndex, Get-HoneyEffectiveOverall diff --git a/win/report.ps1 b/win/report.ps1 index 31cc5d8..3767928 100644 --- a/win/report.ps1 +++ b/win/report.ps1 @@ -9,14 +9,18 @@ .NOTES THE VERDICT IS THE `OVERALL:` LINE — worst status across bumblebee and all - active lenses. Exit 0 when OVERALL clean, 1 otherwise. This is the source of - truth the routine must trust (never the bumblebee manifest alone). + active lenses, AFTER the pin-and-diff suppression baseline is applied. Pinned, + content-unchanged findings are suppressed (dropped from the verdict, still + counted); a pinned file whose content CHANGED resurfaces as MUTATED. Raw run + records are never modified. Exit 0 when OVERALL clean, 1 otherwise. This is the + source of truth the routine must trust (never the bumblebee manifest alone). #> param([string]$RunDir) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' Import-Module (Join-Path $PSScriptRoot 'lib' 'Honey.psm1') -Force +Import-Module (Join-Path $PSScriptRoot 'lib' 'Baseline.psm1') -Force Import-HoneyConfig $honeyRoot = Get-HoneyRoot @@ -48,6 +52,11 @@ if (Test-Path $sumPath) { $L = New-Object System.Collections.ArrayList function Emit { param($s) [void]$L.Add($s) } +# Suppression tallies, accumulated across bumblebee + every lens as we render. +$script:sup = 0; $script:mut = 0; $script:exp = 0 +# A class marker printed before a finding line (mutated/expired resurface loudly). +function Get-ClassMarker { param($c) switch ($c) { 'mutated' { '[MUTATED] ' } 'expired' { '[EXPIRED-PIN] ' } default { '' } } } + Emit "honey scan report ($when)" Emit "host: $honHost scanned: $scanRoot files: $files scanner: $ver" Emit "" @@ -67,16 +76,28 @@ switch ($bbStatus) { if (Test-Path $diag) { Get-Content -LiteralPath $diag -Tail 5 | ForEach-Object { Emit " $_" } } } 'exposed' { - $bySev = Get-Prop $manifest 'findings_by_severity' ([pscustomobject]@{}) - $sevStr = ($bySev.PSObject.Properties | ForEach-Object { "$($_.Value) $($_.Name)" }) -join ', ' - Emit "[!!] bumblebee: EXPOSED - $bbTotal match(es): $sevStr" $fp = Join-Path $RunDir 'findings.ndjson' - if (Test-Path $fp) { - Get-Content -LiteralPath $fp | Where-Object { $_.Trim() } | ForEach-Object { - $f = $_ | ConvertFrom-Json - Emit (" - {0} {1}@{2} ({3})" -f (Get-Prop $f 'severity' '?').ToUpper(), (Get-Prop $f 'package_name' '?'), (Get-Prop $f 'version' '?'), (Get-Prop $f 'ecosystem' '?')) - Emit (" campaign : {0}" -f (Get-Prop $f 'catalog_name' '?')) - Emit (" where : {0}" -f (Get-Prop $f 'source_file' '?')) + $recs = @() + if (Test-Path $fp) { $recs = Get-Content -LiteralPath $fp | Where-Object { $_.Trim() } | ForEach-Object { $_ | ConvertFrom-Json } } + $cls = Get-HoneyClassifiedBumblebee -Records @($recs) + $bs = @($cls | Where-Object { $_._class -eq 'suppressed' }).Count + $active = @($cls | Where-Object { $_._class -ne 'suppressed' }) + $script:sup += $bs + $script:mut += @($cls | Where-Object { $_._class -eq 'mutated' }).Count + $script:exp += @($cls | Where-Object { $_._class -eq 'expired' }).Count + if ($active.Count -eq 0) { + Emit "[OK] bumblebee: CLEAN - all $bbTotal match(es) suppressed by baseline (review with honey-baseline.ps1)." + } else { + $overall = Resolve-WorseStatus $overall 'exposed' + $bySev = @($active) | Group-Object severity | ForEach-Object { "$($_.Count) $($_.Name)" } + $supStr = if ($bs -gt 0) { " (+$bs suppressed)" } else { "" } + Emit "[!!] bumblebee: EXPOSED - $($active.Count) match(es): $(( $bySev ) -join ', ')$supStr" + $rank = @{ critical=0; high=1; medium=2; low=3; unknown=4 } + @($active) | Sort-Object @{ Expression = { $r = $rank[[string](Get-Prop $_ 'severity' 'unknown')]; if ($null -eq $r) { 9 } else { $r } } } | ForEach-Object { + Emit (" - {0}{1} {2}@{3} ({4})" -f (Get-ClassMarker $_._class), (Get-Prop $_ 'severity' '?').ToUpper(), (Get-Prop $_ 'package_name' '?'), (Get-Prop $_ 'version' '?'), (Get-Prop $_ 'ecosystem' '?')) + Emit (" campaign : {0}" -f (Get-Prop $_ 'catalog_name' '?')) + Emit (" where : {0}" -f (Get-Prop $_ 'source_file' '?')) + if ($_._class -eq 'mutated') { Emit " note : content changed since this was pinned reviewed-benign - re-review before re-pinning." } } } } @@ -89,39 +110,63 @@ Get-ChildItem -Path $RunDir -Filter 'lens-*.json' -ErrorAction SilentlyContinue $st = Get-Prop $lj 'status' 'unknown' $tot = Get-Prop $lj 'findings_total' 0 $note = Get-Prop $lj 'note' '' - $overall = Resolve-WorseStatus $overall $st Emit "" switch ($st) { 'skipped' { Emit "-- lens ${name}: skipped ($note)" } 'clean' { Emit "[OK] lens ${name}: clean ($note)" } - 'incomplete' { Emit "[!] lens ${name}: incomplete ($note)" } - 'scan_error' { Emit "[X] lens ${name}: scan error ($note)" } + 'incomplete' { Emit "[!] lens ${name}: incomplete ($note)"; $overall = Resolve-WorseStatus $overall 'incomplete' } + 'scan_error' { Emit "[X] lens ${name}: scan error ($note)"; $overall = Resolve-WorseStatus $overall 'scan_error' } 'exposed' { - $bySev = Get-Prop $lj 'findings_by_severity' ([pscustomobject]@{}) - $sevStr = ($bySev.PSObject.Properties | ForEach-Object { "$($_.Value) $($_.Name)" }) -join ', ' - Emit "[!!] lens ${name}: $tot finding(s) [$sevStr] ($note)" - $rank = @{ critical=0; high=1; medium=2; low=3; unknown=4 } - @(Get-Prop $lj 'findings' @()) | - Sort-Object @{ Expression = { $r = $rank[[string](Get-Prop $_ 'severity' 'unknown')]; if ($null -eq $r) { 9 } else { $r } } } | - ForEach-Object { - Emit (" - {0} {1}" -f (Get-Prop $_ 'severity' '?').ToUpper(), (Get-Prop $_ 'title' '?')) + $cls = Get-HoneyClassifiedLens -Scanner $name -LensObj $lj + $s = @($cls | Where-Object { $_._class -eq 'suppressed' }).Count + $active = @($cls | Where-Object { $_._class -ne 'suppressed' }) + $script:sup += $s + $script:mut += @($cls | Where-Object { $_._class -eq 'mutated' }).Count + $script:exp += @($cls | Where-Object { $_._class -eq 'expired' }).Count + if ($active.Count -eq 0) { + Emit "[OK] lens ${name}: clean - all $tot finding(s) suppressed by baseline. ($note)" + } else { + $overall = Resolve-WorseStatus $overall 'exposed' + $bySev = @($active) | Group-Object severity | ForEach-Object { "$($_.Count) $($_.Name)" } + $supStr = if ($s -gt 0) { " (+$s suppressed)" } else { "" } + Emit "[!!] lens ${name}: $($active.Count) finding(s) [$(( $bySev ) -join ', ')]$supStr ($note)" + $rank = @{ critical=0; high=1; medium=2; low=3; unknown=4 } + @($active) | Sort-Object @{ Expression = { $r = $rank[[string](Get-Prop $_ 'severity' 'unknown')]; if ($null -eq $r) { 9 } else { $r } } } | ForEach-Object { + Emit (" - {0}{1} {2}" -f (Get-ClassMarker $_._class), (Get-Prop $_ 'severity' '?').ToUpper(), (Get-Prop $_ 'title' '?')) $loc = Get-Prop $_ 'location' '' if ($loc) { Emit " where : $loc" } $det = Get-Prop $_ 'detail' '' if ($det) { Emit " detail: $det" } + if ($_._class -eq 'mutated') { Emit " note : content changed since this was pinned reviewed-benign - re-review before re-pinning." } } + } } } } +# --- suppression summary ---------------------------------------------------- +if (($script:sup + $script:mut + $script:exp) -gt 0) { + Emit "" + Emit "baseline: $($script:sup) suppressed, $($script:mut) mutated, $($script:exp) expired - honey.baseline.json. Review: honey-baseline.ps1 status" + if ($script:mut -gt 0) { Emit "[!!] $($script:mut) MUTATED: a file pinned as reviewed-benign has CHANGED. Treat as a possible rug pull and re-review." } +} + # --- overall verdict -------------------------------------------------------- +$suffix = "" +if (($script:sup + $script:mut + $script:exp) -gt 0) { + $suffix = " ($($script:sup) suppressed" + if ($script:mut -gt 0) { $suffix += ", $($script:mut) mutated" } + if ($script:exp -gt 0) { $suffix += ", $($script:exp) expired" } + $suffix += ")" +} + Emit "" if ($overall -eq 'clean') { - Emit "OVERALL: CLEAN across bumblebee and all active lenses." + Emit "OVERALL: CLEAN$suffix across bumblebee and all active lenses." $L | ForEach-Object { Write-Output $_ } exit 0 } -Emit ("OVERALL: {0} - address critical/high items first." -f $overall.ToUpper()) +Emit ("OVERALL: {0}{1} - address critical/high items first." -f $overall.ToUpper(), $suffix) Emit "Verify each fix manually; honey only reports, it never changes your system. Raw records under: $RunDir" $L | ForEach-Object { Write-Output $_ } exit 1 From 8b64e018dd2ebcdd61b484db3f019efaa2c6cc17 Mon Sep 17 00:00:00 2001 From: eric-sabe Date: Tue, 7 Jul 2026 19:40:33 -0400 Subject: [PATCH 2/2] fix(ci): rewrite two A && B || C idioms as if/then/else (SC2015) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ubuntu's shellcheck flags SC2015 on the [ -f ] && [ -r ] || {...} hash guard and the [ $# -gt 0 ] && shift || true dispatch line (info-level, but CI treats it as a failure); homebrew shellcheck 0.11 does not. Rewrite both as explicit if/then/else — behavior identical, CI green. Co-Authored-By: Claude Opus 4.8 (1M context) --- honey-baseline.sh | 2 +- lib/baseline.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/honey-baseline.sh b/honey-baseline.sh index 781e9df..8d9428f 100755 --- a/honey-baseline.sh +++ b/honey-baseline.sh @@ -228,7 +228,7 @@ cmd_remove() { # --- dispatch --------------------------------------------------------------- -cmd="${1:-}"; [ "$#" -gt 0 ] && shift || true +cmd="${1:-}"; if [ "$#" -gt 0 ]; then shift; fi case "$cmd" in status) cmd_status "$@" ;; add) cmd_add "$@" ;; diff --git a/lib/baseline.sh b/lib/baseline.sh index 0afb4d2..acdcaef 100755 --- a/lib/baseline.sh +++ b/lib/baseline.sh @@ -62,7 +62,7 @@ honey_loc_file() { # edit changes the bytes -> hash mismatch -> resurfaces. No NFC folding. honey_hash_file() { local f="$1" h="" - [ -f "$f" ] && [ -r "$f" ] || { printf ''; return; } + if [ ! -f "$f" ] || [ ! -r "$f" ]; then printf ''; return; fi if command -v sha256sum >/dev/null 2>&1; then h="$(sha256sum "$f" 2>/dev/null | awk '{print $1}')" elif command -v shasum >/dev/null 2>&1; then