From d33b2a96290fe1238cc6690f635ba9c4d82495fa Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 20 Jul 2026 01:30:10 +0200 Subject: [PATCH 1/9] chore: claim #2286 From 0b938a1059062fa3a308fa1b5e7f827b689ed3fd Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 20 Jul 2026 01:32:28 +0200 Subject: [PATCH 2/9] fix(agent): reject Kubescape LIST skeleton false alarms --- .claude/scripts/portfolio-surveyor.test.sh | 9 +++++++++ .claude/skills/products/platform/SKILL.md | 8 ++++++++ .github/workflows/ci.yaml | 2 ++ 3 files changed, 19 insertions(+) diff --git a/.claude/scripts/portfolio-surveyor.test.sh b/.claude/scripts/portfolio-surveyor.test.sh index 018af10f..cfb95fce 100755 --- a/.claude/scripts/portfolio-surveyor.test.sh +++ b/.claude/scripts/portfolio-surveyor.test.sh @@ -11,12 +11,21 @@ monorepo_skill="${repo_root}/.claude/skills/products/monorepo/SKILL.md" product_engineering_skill="${repo_root}/.claude/skills/product-engineering/SKILL.md" agent_skills_card="${repo_root}/.claude/skills/products/agent-skills/SKILL.md" ksail_card="${repo_root}/.claude/skills/products/ksail/SKILL.md" +platform_card="${repo_root}/.claude/skills/products/platform/SKILL.md" +platform_security_surveyor="${repo_root}/.claude/agents/platform-security-surveyor.md" fail() { echo "portfolio surveyor contract: FAIL — $*" >&2 exit 1 } +for security_definition in "${platform_card}" "${platform_security_surveyor}"; do + grep -Fq 'Kubescape CR LISTs return spec-stripped skeletons.' "${security_definition}" || + fail "${security_definition#"${repo_root}/"} can mistake a Kubescape LIST skeleton for empty scanner output" + grep -Fq 'sample 2–3 objects per surface' "${security_definition}" || + fail "${security_definition#"${repo_root}/"} does not require direct per-object Kubescape payload checks" +done + [[ -x "${classifier}" ]] || fail "release-bot exemption classifier is missing or not executable" grep -Fq '.claude/scripts/release-bot-exemption.sh' "${surveyor}" || fail "surveyor does not delegate exemption decisions to the exact classifier" diff --git a/.claude/skills/products/platform/SKILL.md b/.claude/skills/products/platform/SKILL.md index 963d6a26..25ce9b51 100644 --- a/.claude/skills/products/platform/SKILL.md +++ b/.claude/skills/products/platform/SKILL.md @@ -71,6 +71,14 @@ The recurring trap, learned the hard way (2026-07-04, all three surfaces were si empty/zero reading almost always means the scanner is broken, not that the cluster is clean** — a broken scanner and a compliant cluster look identical, so **check liveness first, every time**: +**Probe rule that overrides reflex:** **Kubescape CR LISTs return spec-stripped skeletons.** Use a +cluster-wide LIST only to select object names and inspect metadata; all-zero severities, empty matches, +or empty VEX payloads in that response are display artifacts, not findings. Before judging payload +quality, `kubectl get -n -o json` by name and **sample 2–3 objects per surface**. For +CVE liveness, directly GET both named `vulnerabilitymanifests` and their corresponding +`vulnerabilitymanifestsummaries`, then cross-check scanner freshness and logs. Never declare the +scanner broken or the cluster clean from a LIST projection. + - **Posture** (config scan) — `configurationscansummaries` / `workloadconfigurationscansummaries` (per-namespace scores + failed controls). **Broken if** scores are `0.00` across frameworks, `controls: null` en masse, or objects are days stale — check the `kubescape` scanner pod logs for scan diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a0b93cda..5e6d9694 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -93,11 +93,13 @@ jobs: - 'AGENTS.md' - '.claude/agents/daily-maintainer.md' - '.claude/agents/portfolio-surveyor.md' + - '.claude/agents/platform-security-surveyor.md' - '.claude/skills/portfolio-maintenance/SKILL.md' - '.claude/skills/product-engineering/SKILL.md' - '.claude/skills/products/agent-skills/SKILL.md' - '.claude/skills/products/ksail/SKILL.md' - '.claude/skills/products/monorepo/SKILL.md' + - '.claude/skills/products/platform/SKILL.md' - '.claude/scripts/portfolio-surveyor.test.sh' - '.claude/scripts/release-bot-exemption.sh' - '.github/workflows/ci.yaml' From 5a53b99588d280e49aa49973a0801e616bb6976f Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 20 Jul 2026 01:41:33 +0200 Subject: [PATCH 3/9] fix(agent): separate Kubescape liveness from findings --- .claude/agents/platform-security-surveyor.md | 12 +++++++++--- .claude/scripts/portfolio-surveyor.test.sh | 6 ++++++ .claude/skills/products/platform/SKILL.md | 5 ++++- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.claude/agents/platform-security-surveyor.md b/.claude/agents/platform-security-surveyor.md index d1fd2218..2966a6a4 100644 --- a/.claude/agents/platform-security-surveyor.md +++ b/.claude/agents/platform-security-surveyor.md @@ -27,6 +27,10 @@ acts on it, not a human); return the digest and nothing else. all-zero severities and empty VEX/matches even when the real objects are full — you MUST `kubectl get -n -o json` **by name** (sample 2–3 objects per surface) before concluding anything about data quality. An all-zero LIST is a *display artifact*, not a finding. +Sampling proves **liveness only**. Before reporting cluster-wide scores, severity totals, finding +deltas, or cleanliness, **directly GET every object whose payload contributes** (or use a trusted +aggregate endpoint already verified against direct samples); never extrapolate from the liveness +sample. And the standing trap this agent exists to catch: **an empty/zero reading almost always means the scanner is BROKEN, not that the cluster is clean** — a broken scanner and a compliant cluster read identically, so every surface check is **liveness first, values second**. @@ -38,9 +42,11 @@ identically, so every surface check is **liveness first, values second**. --tail=50` for scan aborts. Then: framework scores + the top failed controls (id, name, failing count) vs baseline. 2. **CVE (kubevuln)** — `vulnerabilitymanifestsummaries` / `vulnerabilitymanifests` (+ `openvulnerabilityexchangecontainers` - for VEX). Liveness: GET 2–3 summaries by name — real grype `matches`/`tool.name` present, `.all` - and `.relevant` populated; on suspicion, kubevuln logs for `ScanCP … partial`. Then: critical/high - counts (all vs relevant), notable new reachable CVEs vs baseline, VEX doc count. + for VEX). Liveness: directly GET both named `vulnerabilitymanifests` and their corresponding + `vulnerabilitymanifestsummaries` for 2–3 workloads — real grype `matches`/`tool.name` present, + `.all` and `.relevant` populated; on suspicion, kubevuln logs for `ScanCP … partial`. Then: + critical/high counts (all vs relevant), notable new reachable CVEs vs baseline, VEX doc count, + collected only from the direct-object/verified-aggregate rule above. 3. **Runtime (node-agent)** — `applicationprofiles`, `networkneighborhoods`, alert routing. Liveness: profiles present and not all `partial`; routing **visible, not stdout-only** (exporter config / `alertManagerExporterUrls` / Prometheus exporter state, and whether alerts reach the routed sink — diff --git a/.claude/scripts/portfolio-surveyor.test.sh b/.claude/scripts/portfolio-surveyor.test.sh index cfb95fce..aee3864b 100755 --- a/.claude/scripts/portfolio-surveyor.test.sh +++ b/.claude/scripts/portfolio-surveyor.test.sh @@ -24,6 +24,12 @@ for security_definition in "${platform_card}" "${platform_security_surveyor}"; d fail "${security_definition#"${repo_root}/"} can mistake a Kubescape LIST skeleton for empty scanner output" grep -Fq 'sample 2–3 objects per surface' "${security_definition}" || fail "${security_definition#"${repo_root}/"} does not require direct per-object Kubescape payload checks" + grep -Fq 'directly GET every object whose payload contributes' "${security_definition}" || + fail "${security_definition#"${repo_root}/"} can extrapolate cluster-wide findings from a liveness sample" + # Literal Markdown code spans; command substitution is intentionally disabled. + # shellcheck disable=SC2016 + grep -Fq 'both named `vulnerabilitymanifests` and their corresponding' "${security_definition}" || + fail "${security_definition#"${repo_root}/"} does not require direct reads of both CVE object types" done [[ -x "${classifier}" ]] || fail "release-bot exemption classifier is missing or not executable" diff --git a/.claude/skills/products/platform/SKILL.md b/.claude/skills/products/platform/SKILL.md index 25ce9b51..03950cfa 100644 --- a/.claude/skills/products/platform/SKILL.md +++ b/.claude/skills/products/platform/SKILL.md @@ -77,7 +77,10 @@ or empty VEX payloads in that response are display artifacts, not findings. Befo quality, `kubectl get -n -o json` by name and **sample 2–3 objects per surface**. For CVE liveness, directly GET both named `vulnerabilitymanifests` and their corresponding `vulnerabilitymanifestsummaries`, then cross-check scanner freshness and logs. Never declare the -scanner broken or the cluster clean from a LIST projection. +scanner broken or the cluster clean from a LIST projection. Sampling proves **liveness only**. Before +reporting cluster-wide scores, severity totals, finding deltas, or cleanliness, +directly GET every object whose payload contributes (or use a trusted aggregate endpoint already +verified against direct samples). Never extrapolate cluster-wide findings from the liveness sample. - **Posture** (config scan) — `configurationscansummaries` / `workloadconfigurationscansummaries` (per-namespace scores + failed controls). **Broken if** scores are `0.00` across frameworks, From 0fc93f314b9eda20f330d9090eee47c55308e58b Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 20 Jul 2026 01:46:48 +0200 Subject: [PATCH 4/9] fix(agent): accept Kubescape scanner version evidence --- .claude/agents/platform-security-surveyor.md | 10 ++++++---- .claude/scripts/portfolio-surveyor.test.sh | 2 ++ .claude/skills/products/platform/SKILL.md | 13 ++++++++----- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/.claude/agents/platform-security-surveyor.md b/.claude/agents/platform-security-surveyor.md index 2966a6a4..5d050958 100644 --- a/.claude/agents/platform-security-surveyor.md +++ b/.claude/agents/platform-security-surveyor.md @@ -43,10 +43,12 @@ identically, so every surface check is **liveness first, values second**. count) vs baseline. 2. **CVE (kubevuln)** — `vulnerabilitymanifestsummaries` / `vulnerabilitymanifests` (+ `openvulnerabilityexchangecontainers` for VEX). Liveness: directly GET both named `vulnerabilitymanifests` and their corresponding - `vulnerabilitymanifestsummaries` for 2–3 workloads — real grype `matches`/`tool.name` present, - `.all` and `.relevant` populated; on suspicion, kubevuln logs for `ScanCP … partial`. Then: - critical/high counts (all vs relevant), notable new reachable CVEs vs baseline, VEX doc count, - collected only from the direct-object/verified-aggregate rule above. + `vulnerabilitymanifestsummaries` for 2–3 workloads — **Grype matches plus scanner version metadata** + (`spec.metadata.tool.version` or `kubescape.io/tool-version`) observed across the sample, with `.all` + and `.relevant` refs/counters populated. Do not require `spec.metadata.tool.name`; healthy current + objects leave it blank. On suspicion, check kubevuln logs for `ScanCP … partial`. Then: critical/high + counts (all vs relevant), notable new reachable CVEs vs baseline, VEX doc count, collected only from + the direct-object/verified-aggregate rule above. 3. **Runtime (node-agent)** — `applicationprofiles`, `networkneighborhoods`, alert routing. Liveness: profiles present and not all `partial`; routing **visible, not stdout-only** (exporter config / `alertManagerExporterUrls` / Prometheus exporter state, and whether alerts reach the routed sink — diff --git a/.claude/scripts/portfolio-surveyor.test.sh b/.claude/scripts/portfolio-surveyor.test.sh index aee3864b..e0aa379a 100755 --- a/.claude/scripts/portfolio-surveyor.test.sh +++ b/.claude/scripts/portfolio-surveyor.test.sh @@ -30,6 +30,8 @@ for security_definition in "${platform_card}" "${platform_security_surveyor}"; d # shellcheck disable=SC2016 grep -Fq 'both named `vulnerabilitymanifests` and their corresponding' "${security_definition}" || fail "${security_definition#"${repo_root}/"} does not require direct reads of both CVE object types" + grep -Fq 'Grype matches plus scanner version metadata' "${security_definition}" || + fail "${security_definition#"${repo_root}/"} can treat Kubescape's blank tool.name as a scanner outage" done [[ -x "${classifier}" ]] || fail "release-bot exemption classifier is missing or not executable" diff --git a/.claude/skills/products/platform/SKILL.md b/.claude/skills/products/platform/SKILL.md index 03950cfa..b62e00d2 100644 --- a/.claude/skills/products/platform/SKILL.md +++ b/.claude/skills/products/platform/SKILL.md @@ -88,11 +88,14 @@ verified against direct samples). Never extrapolate cluster-wide findings from t aborts. Note: the CI gate (`ksail workload scan --framework nsa --compliance-threshold N`) is a **separate** static scan in a healthy CLI context — **a green CI gate does NOT prove the in-cluster scan works.** -- **CVE** (kubevuln) — `vulnerabilitymanifestsummaries` / `vulnerabilitymanifests`. **Broken if** - manifests carry no grype `matches` / no `tool.name` and summaries are empty (both `.all` and - `.relevant`) — check kubevuln logs for `ScanCP … partial` (the relevancy path aborting on partial - ApplicationProfiles). Prioritise by relevancy × severity × fixability; emit VEX to suppress - non-reachable CVEs. +- **CVE** (kubevuln) — `vulnerabilitymanifestsummaries` / `vulnerabilitymanifests`. Liveness requires + **Grype matches plus scanner version metadata** (`spec.metadata.tool.version` or the + `kubescape.io/tool-version` annotation) across the direct samples, with populated summary refs and + counters. A blank `spec.metadata.tool.name` alone is an upstream representation quirk, not an outage. + **Broken if** direct manifests carry neither matches nor scanner-version evidence and summaries are + empty (both `.all` and `.relevant`) — check kubevuln logs for `ScanCP … partial` (the relevancy path + aborting on partial ApplicationProfiles). Prioritise by relevancy × severity × fixability; emit VEX + to suppress non-reachable CVEs. - **Runtime** (node-agent) — `applicationprofiles`, `networkneighborhoods`, and the `node_agent_alert_counter` metric. **Invisible if** the exporters are stdout-only (`alertManagerExporterUrls: []`, `prometheusExporterEnabled: false`). Route **natively to Coroot** From 7b606514cb7793a9b7cf56ebb707234b956f5a64 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 20 Jul 2026 01:53:20 +0200 Subject: [PATCH 5/9] fix(agent): bound Kubescape coverage reporting --- .claude/agents/platform-security-surveyor.md | 18 +++++++++++------ .claude/scripts/portfolio-surveyor.test.sh | 6 ++++++ .claude/skills/products/platform/SKILL.md | 21 ++++++++++++-------- 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/.claude/agents/platform-security-surveyor.md b/.claude/agents/platform-security-surveyor.md index 5d050958..1f715c3f 100644 --- a/.claude/agents/platform-security-surveyor.md +++ b/.claude/agents/platform-security-surveyor.md @@ -27,10 +27,14 @@ acts on it, not a human); return the digest and nothing else. all-zero severities and empty VEX/matches even when the real objects are full — you MUST `kubectl get -n -o json` **by name** (sample 2–3 objects per surface) before concluding anything about data quality. An all-zero LIST is a *display artifact*, not a finding. -Sampling proves **liveness only**. Before reporting cluster-wide scores, severity totals, finding -deltas, or cleanliness, **directly GET every object whose payload contributes** (or use a trusted -aggregate endpoint already verified against direct samples); never extrapolate from the liveness -sample. +Use **LIST metadata for coverage and freshness** only: reconcile result names, identity labels, +manifest/summary pairs, and timestamps against the **current workload/container inventory** with +bounded metadata LISTs. A missing or stale pair is a coverage gap, not zero findings. Sampling proves +**liveness only**. If the contributing set fits the ~dozen-read bound, +directly GET every object whose payload contributes; otherwise use a trusted aggregate endpoint already +verified against direct samples. When neither a payload-complete source nor complete inventory coverage fits the bound, +**report the cluster-wide result as unavailable or partial** and name the missing proof; never +extrapolate from the liveness sample. And the standing trap this agent exists to catch: **an empty/zero reading almost always means the scanner is BROKEN, not that the cluster is clean** — a broken scanner and a compliant cluster read identically, so every surface check is **liveness first, values second**. @@ -61,7 +65,8 @@ identically, so every surface check is **liveness first, values second**. ## Security digest — (baseline: ) scanners_alive: posture= cve= runtime= posture: score vs baseline — top failed: , … -cve: crit/high all=/ relevant=/ — new reachable: , … ; vex_docs= +coverage: cve= +cve: crit/high all=/| relevant=/| — new reachable: , … ; vex_docs= runtime: new detections — routing= ci_gate: threshold= (in-cluster agrees|DIVERGES: ) deltas_needing_action: @@ -70,4 +75,5 @@ deltas_needing_action: Digest rules: **classify, don't decide** — the orchestrator turns confirmed deltas into `security` issues under the epic (platform#2447) or a hotfix; you never file, comment, or fix. A broken/invisible scanner is itself a `deltas_needing_action` line (worst class of finding). If the context/credentials -are unavailable, say so in one line and stop — never guess from stale data. +are unavailable, say so in one line and stop — never guess from stale data. A partial coverage set or +missing payload-complete aggregate is also explicit `unavailable`/`partial` evidence, never a zero. diff --git a/.claude/scripts/portfolio-surveyor.test.sh b/.claude/scripts/portfolio-surveyor.test.sh index e0aa379a..ec6ddce4 100755 --- a/.claude/scripts/portfolio-surveyor.test.sh +++ b/.claude/scripts/portfolio-surveyor.test.sh @@ -32,6 +32,12 @@ for security_definition in "${platform_card}" "${platform_security_surveyor}"; d fail "${security_definition#"${repo_root}/"} does not require direct reads of both CVE object types" grep -Fq 'Grype matches plus scanner version metadata' "${security_definition}" || fail "${security_definition#"${repo_root}/"} can treat Kubescape's blank tool.name as a scanner outage" + grep -Fq 'LIST metadata for coverage and freshness' "${security_definition}" || + fail "${security_definition#"${repo_root}/"} does not define a bounded use for Kubescape LISTs" + grep -Fq 'current workload/container inventory' "${security_definition}" || + fail "${security_definition#"${repo_root}/"} can miss workloads with no vulnerability result object" + grep -Fq 'report the cluster-wide result as unavailable or partial' "${security_definition}" || + fail "${security_definition#"${repo_root}/"} can overclaim cluster-wide findings when bounded proof is unavailable" done [[ -x "${classifier}" ]] || fail "release-bot exemption classifier is missing or not executable" diff --git a/.claude/skills/products/platform/SKILL.md b/.claude/skills/products/platform/SKILL.md index b62e00d2..bba5fbf1 100644 --- a/.claude/skills/products/platform/SKILL.md +++ b/.claude/skills/products/platform/SKILL.md @@ -72,15 +72,20 @@ empty/zero reading almost always means the scanner is broken, not that the clust scanner and a compliant cluster look identical, so **check liveness first, every time**: **Probe rule that overrides reflex:** **Kubescape CR LISTs return spec-stripped skeletons.** Use a -cluster-wide LIST only to select object names and inspect metadata; all-zero severities, empty matches, -or empty VEX payloads in that response are display artifacts, not findings. Before judging payload -quality, `kubectl get -n -o json` by name and **sample 2–3 objects per surface**. For -CVE liveness, directly GET both named `vulnerabilitymanifests` and their corresponding +cluster-wide LIST only as **LIST metadata for coverage and freshness**: select object names, timestamps, +and identity labels; all-zero severities, empty matches, or empty VEX payloads in that response are +display artifacts, not findings. For CVE coverage, reconcile the result identities and manifest/summary +pairs against the **current workload/container inventory** before making any cluster-wide claim; an +absent or stale result is a coverage gap, not zero findings. Before judging payload quality, +`kubectl get -n -o json` by name and **sample 2–3 objects per surface**. For CVE +liveness, directly GET both named `vulnerabilitymanifests` and their corresponding `vulnerabilitymanifestsummaries`, then cross-check scanner freshness and logs. Never declare the -scanner broken or the cluster clean from a LIST projection. Sampling proves **liveness only**. Before -reporting cluster-wide scores, severity totals, finding deltas, or cleanliness, -directly GET every object whose payload contributes (or use a trusted aggregate endpoint already -verified against direct samples). Never extrapolate cluster-wide findings from the liveness sample. +scanner broken or the cluster clean from a LIST projection. Sampling proves **liveness only**. If the +contributing set fits the read bound, directly GET every object whose payload contributes; otherwise +use a trusted aggregate endpoint already verified against direct samples. When neither a payload- +complete source nor complete inventory coverage can be established safely, +**report the cluster-wide result as unavailable or partial** and name the missing proof. Never +extrapolate cluster-wide findings from the liveness sample. - **Posture** (config scan) — `configurationscansummaries` / `workloadconfigurationscansummaries` (per-namespace scores + failed controls). **Broken if** scores are `0.00` across frameworks, From 0e75f16b2ab7dae255bec4cd112dfa94481764fe Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 20 Jul 2026 01:55:00 +0200 Subject: [PATCH 6/9] fix(agent): accept coherent zero-vulnerability scans --- .claude/agents/platform-security-surveyor.md | 14 ++++++++------ .claude/scripts/portfolio-surveyor.test.sh | 2 +- .claude/skills/products/platform/SKILL.md | 7 ++++--- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.claude/agents/platform-security-surveyor.md b/.claude/agents/platform-security-surveyor.md index 1f715c3f..cbfb4583 100644 --- a/.claude/agents/platform-security-surveyor.md +++ b/.claude/agents/platform-security-surveyor.md @@ -47,12 +47,14 @@ identically, so every surface check is **liveness first, values second**. count) vs baseline. 2. **CVE (kubevuln)** — `vulnerabilitymanifestsummaries` / `vulnerabilitymanifests` (+ `openvulnerabilityexchangecontainers` for VEX). Liveness: directly GET both named `vulnerabilitymanifests` and their corresponding - `vulnerabilitymanifestsummaries` for 2–3 workloads — **Grype matches plus scanner version metadata** - (`spec.metadata.tool.version` or `kubescape.io/tool-version`) observed across the sample, with `.all` - and `.relevant` refs/counters populated. Do not require `spec.metadata.tool.name`; healthy current - objects leave it blank. On suspicion, check kubevuln logs for `ScanCP … partial`. Then: critical/high - counts (all vs relevant), notable new reachable CVEs vs baseline, VEX doc count, collected only from - the direct-object/verified-aggregate rule above. + `vulnerabilitymanifestsummaries` for 2–3 workloads. + Accept **Grype matches or scanner version metadata with coherent summaries** + (`spec.metadata.tool.version` or `kubescape.io/tool-version`) across the sample. A genuinely zero- + match image is healthy when version evidence, summary refs, and + zero counters agree. Do not require `spec.metadata.tool.name`; healthy current objects leave it blank. + On suspicion, check kubevuln logs for `ScanCP … partial`. Then: critical/high counts (all vs relevant), + notable new reachable CVEs vs baseline, VEX doc count, collected only from the direct-object/verified- + aggregate rule above. 3. **Runtime (node-agent)** — `applicationprofiles`, `networkneighborhoods`, alert routing. Liveness: profiles present and not all `partial`; routing **visible, not stdout-only** (exporter config / `alertManagerExporterUrls` / Prometheus exporter state, and whether alerts reach the routed sink — diff --git a/.claude/scripts/portfolio-surveyor.test.sh b/.claude/scripts/portfolio-surveyor.test.sh index ec6ddce4..b0c019c9 100755 --- a/.claude/scripts/portfolio-surveyor.test.sh +++ b/.claude/scripts/portfolio-surveyor.test.sh @@ -30,7 +30,7 @@ for security_definition in "${platform_card}" "${platform_security_surveyor}"; d # shellcheck disable=SC2016 grep -Fq 'both named `vulnerabilitymanifests` and their corresponding' "${security_definition}" || fail "${security_definition#"${repo_root}/"} does not require direct reads of both CVE object types" - grep -Fq 'Grype matches plus scanner version metadata' "${security_definition}" || + grep -Fq 'Grype matches or scanner version metadata with coherent summaries' "${security_definition}" || fail "${security_definition#"${repo_root}/"} can treat Kubescape's blank tool.name as a scanner outage" grep -Fq 'LIST metadata for coverage and freshness' "${security_definition}" || fail "${security_definition#"${repo_root}/"} does not define a bounded use for Kubescape LISTs" diff --git a/.claude/skills/products/platform/SKILL.md b/.claude/skills/products/platform/SKILL.md index bba5fbf1..90180e26 100644 --- a/.claude/skills/products/platform/SKILL.md +++ b/.claude/skills/products/platform/SKILL.md @@ -94,9 +94,10 @@ extrapolate cluster-wide findings from the liveness sample. **separate** static scan in a healthy CLI context — **a green CI gate does NOT prove the in-cluster scan works.** - **CVE** (kubevuln) — `vulnerabilitymanifestsummaries` / `vulnerabilitymanifests`. Liveness requires - **Grype matches plus scanner version metadata** (`spec.metadata.tool.version` or the - `kubescape.io/tool-version` annotation) across the direct samples, with populated summary refs and - counters. A blank `spec.metadata.tool.name` alone is an upstream representation quirk, not an outage. + **Grype matches or scanner version metadata with coherent summaries** (`spec.metadata.tool.version` + or the `kubescape.io/tool-version` annotation) across the direct samples. A genuinely zero-match + image is healthy when version evidence, summary refs, and zero counters agree. A blank + `spec.metadata.tool.name` alone is an upstream representation quirk, not an outage. **Broken if** direct manifests carry neither matches nor scanner-version evidence and summaries are empty (both `.all` and `.relevant`) — check kubevuln logs for `ScanCP … partial` (the relevancy path aborting on partial ApplicationProfiles). Prioritise by relevancy × severity × fixability; emit VEX From 7b534e13be28cc3e8939867f74a2fd8a07c74fb5 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 20 Jul 2026 02:14:02 +0200 Subject: [PATCH 7/9] fix(agent): make partial Kubescape results actionable --- .claude/agents/platform-security-surveyor.md | 21 ++++++++++++-------- .claude/scripts/portfolio-surveyor.test.sh | 10 ++++++++-- .claude/skills/products/platform/SKILL.md | 13 ++++++++---- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/.claude/agents/platform-security-surveyor.md b/.claude/agents/platform-security-surveyor.md index cbfb4583..8895ad6a 100644 --- a/.claude/agents/platform-security-surveyor.md +++ b/.claude/agents/platform-security-surveyor.md @@ -34,7 +34,10 @@ bounded metadata LISTs. A missing or stale pair is a coverage gap, not zero find directly GET every object whose payload contributes; otherwise use a trusted aggregate endpoint already verified against direct samples. When neither a payload-complete source nor complete inventory coverage fits the bound, **report the cluster-wide result as unavailable or partial** and name the missing proof; never -extrapolate from the liveness sample. +extrapolate from the liveness sample. Bounded proof failure is per-surface: +**posture, CVE, and runtime each report unavailable or partial** instead of inventing a numeric value. +Every confirmed partial coverage gap must also appear in `deltas_needing_action` so the orchestrator +cannot miss a scanner blind spot. And the standing trap this agent exists to catch: **an empty/zero reading almost always means the scanner is BROKEN, not that the cluster is clean** — a broken scanner and a compliant cluster read identically, so every surface check is **liveness first, values second**. @@ -48,10 +51,11 @@ identically, so every surface check is **liveness first, values second**. 2. **CVE (kubevuln)** — `vulnerabilitymanifestsummaries` / `vulnerabilitymanifests` (+ `openvulnerabilityexchangecontainers` for VEX). Liveness: directly GET both named `vulnerabilitymanifests` and their corresponding `vulnerabilitymanifestsummaries` for 2–3 workloads. - Accept **Grype matches or scanner version metadata with coherent summaries** + Accept **(Grype matches or scanner version metadata) with coherent paired summaries** (`spec.metadata.tool.version` or `kubescape.io/tool-version`) across the sample. A genuinely zero- - match image is healthy when version evidence, summary refs, and - zero counters agree. Do not require `spec.metadata.tool.name`; healthy current objects leave it blank. + match image is healthy when version evidence, summary refs, and zero counters agree. Positive + manifest matches with an empty or disagreeing summary are partial, not healthy. Do not require + `spec.metadata.tool.name`; healthy current objects leave it blank. On suspicion, check kubevuln logs for `ScanCP … partial`. Then: critical/high counts (all vs relevant), notable new reachable CVEs vs baseline, VEX doc count, collected only from the direct-object/verified- aggregate rule above. @@ -66,10 +70,10 @@ identically, so every surface check is **liveness first, values second**. ``` ## Security digest — (baseline: ) scanners_alive: posture= cve= runtime= -posture: score vs baseline — top failed: , … -coverage: cve= +posture: score vs baseline — top failed: , … +coverage: posture= cve= runtime= cve: crit/high all=/| relevant=/| — new reachable: , … ; vex_docs= -runtime: new detections — routing= +runtime: new detections — routing= ci_gate: threshold= (in-cluster agrees|DIVERGES: ) deltas_needing_action: - @@ -78,4 +82,5 @@ Digest rules: **classify, don't decide** — the orchestrator turns confirmed de issues under the epic (platform#2447) or a hotfix; you never file, comment, or fix. A broken/invisible scanner is itself a `deltas_needing_action` line (worst class of finding). If the context/credentials are unavailable, say so in one line and stop — never guess from stale data. A partial coverage set or -missing payload-complete aggregate is also explicit `unavailable`/`partial` evidence, never a zero. +missing payload-complete aggregate is also explicit `unavailable`/`partial` evidence, never a zero, +and every confirmed partial coverage gap is repeated in `deltas_needing_action`. diff --git a/.claude/scripts/portfolio-surveyor.test.sh b/.claude/scripts/portfolio-surveyor.test.sh index b0c019c9..8d6dd3c8 100755 --- a/.claude/scripts/portfolio-surveyor.test.sh +++ b/.claude/scripts/portfolio-surveyor.test.sh @@ -30,14 +30,20 @@ for security_definition in "${platform_card}" "${platform_security_surveyor}"; d # shellcheck disable=SC2016 grep -Fq 'both named `vulnerabilitymanifests` and their corresponding' "${security_definition}" || fail "${security_definition#"${repo_root}/"} does not require direct reads of both CVE object types" - grep -Fq 'Grype matches or scanner version metadata with coherent summaries' "${security_definition}" || - fail "${security_definition#"${repo_root}/"} can treat Kubescape's blank tool.name as a scanner outage" + grep -Fq '(Grype matches or scanner version metadata) with coherent paired summaries' "${security_definition}" || + fail "${security_definition#"${repo_root}/"} can accept manifest matches paired with an incoherent CVE summary" grep -Fq 'LIST metadata for coverage and freshness' "${security_definition}" || fail "${security_definition#"${repo_root}/"} does not define a bounded use for Kubescape LISTs" grep -Fq 'current workload/container inventory' "${security_definition}" || fail "${security_definition#"${repo_root}/"} can miss workloads with no vulnerability result object" grep -Fq 'report the cluster-wide result as unavailable or partial' "${security_definition}" || fail "${security_definition#"${repo_root}/"} can overclaim cluster-wide findings when bounded proof is unavailable" + grep -Fq 'posture, CVE, and runtime each report unavailable or partial' "${security_definition}" || + fail "${security_definition#"${repo_root}/"} does not represent bounded proof failures for every Kubescape surface" + # Literal Markdown code spans; command substitution is intentionally disabled. + # shellcheck disable=SC2016 + grep -Fq 'Every confirmed partial coverage gap must also appear in `deltas_needing_action`' "${security_definition}" || + fail "${security_definition#"${repo_root}/"} can report a scanner blind spot without making it actionable" done [[ -x "${classifier}" ]] || fail "release-bot exemption classifier is missing or not executable" diff --git a/.claude/skills/products/platform/SKILL.md b/.claude/skills/products/platform/SKILL.md index 90180e26..7c6be26e 100644 --- a/.claude/skills/products/platform/SKILL.md +++ b/.claude/skills/products/platform/SKILL.md @@ -85,7 +85,10 @@ contributing set fits the read bound, directly GET every object whose payload co use a trusted aggregate endpoint already verified against direct samples. When neither a payload- complete source nor complete inventory coverage can be established safely, **report the cluster-wide result as unavailable or partial** and name the missing proof. Never -extrapolate cluster-wide findings from the liveness sample. +extrapolate cluster-wide findings from the liveness sample. Bounded proof failure is per-surface: +**posture, CVE, and runtime each report unavailable or partial** instead of inventing a numeric value. +Every confirmed partial coverage gap must also appear in `deltas_needing_action` so the orchestrator +turns the blind spot into tracked remediation. - **Posture** (config scan) — `configurationscansummaries` / `workloadconfigurationscansummaries` (per-namespace scores + failed controls). **Broken if** scores are `0.00` across frameworks, @@ -94,9 +97,11 @@ extrapolate cluster-wide findings from the liveness sample. **separate** static scan in a healthy CLI context — **a green CI gate does NOT prove the in-cluster scan works.** - **CVE** (kubevuln) — `vulnerabilitymanifestsummaries` / `vulnerabilitymanifests`. Liveness requires - **Grype matches or scanner version metadata with coherent summaries** (`spec.metadata.tool.version` - or the `kubescape.io/tool-version` annotation) across the direct samples. A genuinely zero-match - image is healthy when version evidence, summary refs, and zero counters agree. A blank + **(Grype matches or scanner version metadata) with coherent paired summaries** + (`spec.metadata.tool.version` or the `kubescape.io/tool-version` annotation) across the direct + samples. A genuinely zero-match image is healthy when version evidence, summary refs, and zero + counters agree; positive manifest matches with an empty or disagreeing summary are partial, not + healthy. A blank `spec.metadata.tool.name` alone is an upstream representation quirk, not an outage. **Broken if** direct manifests carry neither matches nor scanner-version evidence and summaries are empty (both `.all` and `.relevant`) — check kubevuln logs for `ScanCP … partial` (the relevancy path From 3c468a895ba0c33708aeed0ad9e120b0a4bfd678 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 20 Jul 2026 02:25:30 +0200 Subject: [PATCH 8/9] test(agent): pin partial security digest --- .claude/scripts/portfolio-surveyor.test.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.claude/scripts/portfolio-surveyor.test.sh b/.claude/scripts/portfolio-surveyor.test.sh index 8d6dd3c8..668e6fad 100755 --- a/.claude/scripts/portfolio-surveyor.test.sh +++ b/.claude/scripts/portfolio-surveyor.test.sh @@ -46,6 +46,13 @@ for security_definition in "${platform_card}" "${platform_security_surveyor}"; d fail "${security_definition#"${repo_root}/"} can report a scanner blind spot without making it actionable" done +grep -Fq 'posture: score ' "${platform_security_surveyor}" || + fail "platform security digest cannot represent unavailable posture findings" +grep -Fq 'coverage: posture=' "${platform_security_surveyor}" || + fail "platform security digest does not expose per-surface coverage" +grep -Fq 'runtime: new detections' "${platform_security_surveyor}" || + fail "platform security digest cannot represent unavailable runtime findings" + [[ -x "${classifier}" ]] || fail "release-bot exemption classifier is missing or not executable" grep -Fq '.claude/scripts/release-bot-exemption.sh' "${surveyor}" || fail "surveyor does not delegate exemption decisions to the exact classifier" From 4d5e03c092192a93caabd1fd7eb8c1d820759f7d Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Tue, 21 Jul 2026 06:31:56 +0200 Subject: [PATCH 9/9] fix(agent): route incoherent CVE evidence to a PARTIAL state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An incoherent CVE pair — a positive manifest with an empty or disagreeing summary — was already called "partial, not healthy" in the prose, but the digest had nowhere to put it: scanners_alive offered only yes|BROKEN, so the surveyor had to call a not-healthy surface a live one. Add the PARTIAL state, route the incoherent case to it plus a deltas_needing_action line, and pin the CVE unavailable template the contract test had left unguarded. --- .claude/agents/platform-security-surveyor.md | 12 +++++++++--- .claude/scripts/portfolio-surveyor.test.sh | 9 +++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.claude/agents/platform-security-surveyor.md b/.claude/agents/platform-security-surveyor.md index 8895ad6a..52a80d3e 100644 --- a/.claude/agents/platform-security-surveyor.md +++ b/.claude/agents/platform-security-surveyor.md @@ -54,7 +54,10 @@ identically, so every surface check is **liveness first, values second**. Accept **(Grype matches or scanner version metadata) with coherent paired summaries** (`spec.metadata.tool.version` or `kubescape.io/tool-version`) across the sample. A genuinely zero- match image is healthy when version evidence, summary refs, and zero counters agree. Positive - manifest matches with an empty or disagreeing summary are partial, not healthy. Do not require + manifest matches with an empty or disagreeing summary are partial, not healthy — and *partial has a + place to go*: report `scanners_alive: cve=PARTIAL:`, `coverage: cve=PARTIAL:`, and a + `deltas_needing_action` line naming the incoherent pair. Without that routing the only states are + `yes` and `BROKEN`, so an incoherent pair reads as a healthy scanner. Do not require `spec.metadata.tool.name`; healthy current objects leave it blank. On suspicion, check kubevuln logs for `ScanCP … partial`. Then: critical/high counts (all vs relevant), notable new reachable CVEs vs baseline, VEX doc count, collected only from the direct-object/verified- @@ -69,7 +72,7 @@ identically, so every surface check is **liveness first, values second**. ## Return — one compact digest (target < ~600 tokens), this exact shape ``` ## Security digest — (baseline: ) -scanners_alive: posture= cve= runtime= +scanners_alive: posture= cve= runtime= posture: score vs baseline — top failed: , … coverage: posture= cve= runtime= cve: crit/high all=/| relevant=/| — new reachable: , … ; vex_docs= @@ -83,4 +86,7 @@ issues under the epic (platform#2447) or a hotfix; you never file, comment, or f scanner is itself a `deltas_needing_action` line (worst class of finding). If the context/credentials are unavailable, say so in one line and stop — never guess from stale data. A partial coverage set or missing payload-complete aggregate is also explicit `unavailable`/`partial` evidence, never a zero, -and every confirmed partial coverage gap is repeated in `deltas_needing_action`. +and every confirmed partial coverage gap is repeated in `deltas_needing_action`. A surface whose +evidence is incoherent rather than absent — a positive manifest paired with an empty or disagreeing +summary — is `PARTIAL`, never `yes`: `yes` asserts the surface is proven, and there is no state +between `yes` and `BROKEN` unless this one is used. diff --git a/.claude/scripts/portfolio-surveyor.test.sh b/.claude/scripts/portfolio-surveyor.test.sh index 668e6fad..30a2f687 100755 --- a/.claude/scripts/portfolio-surveyor.test.sh +++ b/.claude/scripts/portfolio-surveyor.test.sh @@ -50,8 +50,17 @@ grep -Fq 'posture: score ' "${platform_security_surveyor}" || fail "platform security digest cannot represent unavailable posture findings" grep -Fq 'coverage: posture=' "${platform_security_surveyor}" || fail "platform security digest does not expose per-surface coverage" +grep -Fq 'cve: crit/high all=/|' "${platform_security_surveyor}" || + fail "platform security digest cannot represent unavailable CVE findings" grep -Fq 'runtime: new detections' "${platform_security_surveyor}" || fail "platform security digest cannot represent unavailable runtime findings" +# An incoherent CVE pair is neither proven nor absent. Without a PARTIAL state the +# only choices are `yes` and `BROKEN`, so the digest reports a healthy scanner over +# evidence the prose has already called not-healthy. +grep -Fq 'cve=' "${platform_security_surveyor}" || + fail "platform security digest has no PARTIAL state, so incoherent CVE evidence reads as a live scanner" +grep -Fq 'report `scanners_alive: cve=PARTIAL:`' "${platform_security_surveyor}" || + fail "platform security surveyor does not route an incoherent CVE pair to the PARTIAL state" [[ -x "${classifier}" ]] || fail "release-bot exemption classifier is missing or not executable" grep -Fq '.claude/scripts/release-bot-exemption.sh' "${surveyor}" ||