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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/bridge-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ jobs:
kars::credential_review::digest_tests::review_digest_preserves_compact_sorted_json_and_full_hex_width \
kars::receipt_log::digest_tests::chain_hash_keeps_decimal_sequence_and_exact_pipe_framing \
routes::artifacts::digest_tests::artifact_addresses_keep_the_existing_sixteen_byte_short_form \
routes::operator::audit::datapath::tests::witness_missing_is_unknown_not_not_installed_and_intent_is_separate \
routes::operator::audit::datapath::tests::witness_fresh_and_empty_samples_never_claim_complete_coverage \
routes::operator::audit::datapath::tests::witness_malformed_stale_future_and_identity_fail_closed \
routes::operator::audit::datapath::tests::witness_empty_configmaps_legacy_and_failed_capture_are_distinct \
routes::operator::audit::datapath::tests::witness_route_only_gets_two_fixed_configmaps_and_preserves_api_errors \
routes::github::tests::connection_names_keep_the_original_raw_subject_and_eight_byte_digest \
providers::receipt::tests::rfc8032_known_answer_and_malformed_signatures_keep_exact_verification_semantics \
providers::credential_review::tests::legacy_v1_key_preserves_domain_null_byte_and_raw_secret_encoding \
Expand Down Expand Up @@ -94,6 +99,8 @@ jobs:
- run: cargo test --locked
- name: Check explicit credential review orchestration
run: PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=../tests/native-credentials python3 -m unittest discover -s ../tests/native-credentials -p test_credential_review.py
- name: Check witness producer and BFF matching agreement
run: PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s src/routes/operator -p datapath_matching_contract_test.py

web:
name: Web build and lint
Expand Down Expand Up @@ -168,6 +175,35 @@ jobs:
- run: npm run lint && npm run typecheck && npm run build && npm test
working-directory: bridge/teams-gateway
- run: helm lint bridge/deploy/helm/kars-bridge
- name: Optional witness runtime and server-side ownership contracts
run: |
python3 -m pip install -r deploy/ebpf-witness/tests/requirements.txt
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s deploy/ebpf-witness/aggregator
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s deploy/ebpf-witness/tests -p test_chart.py
- name: Build the public witness image without publishing
run: |
docker build --platform linux/amd64 \
--build-arg PYTHON_BASE=python:3.12-alpine3.22@sha256:a190708a2dec1bd18b1decb539f8e8f5407abaa9bf39cacda583f7f8c11db322 \
--build-arg SOURCE_REVISION="$GITHUB_SHA" \
--file deploy/ebpf-witness/aggregator/Dockerfile \
--tag kars-witness-qualification:latest .
docker run --rm --read-only --network none --cap-drop ALL \
--security-opt no-new-privileges --entrypoint python3 \
kars-witness-qualification:latest -c '
import json, pathlib, subprocess
metadata = json.loads(pathlib.Path("/opt/witness/build.json").read_text())
assert metadata["ig_version"] == "v0.53.2"
assert metadata["architecture"] == "amd64"
assert len(metadata["source_revision"]) == 40
subprocess.run(["kubectl-gadget", "version", "--help"], check=True, capture_output=True)
print(json.dumps(metadata))
' > "$RUNNER_TEMP/witness-image-build.json"
- name: Retain witness image build provenance (not kernel qualification)
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4
with:
name: witness-image-build-${{ github.sha }}
path: ${{ runner.temp }}/witness-image-build.json
if-no-files-found: error
- uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0
with:
cluster_name: bridge-addon-lifecycle
Expand All @@ -178,6 +214,11 @@ jobs:
BRIDGE_TEST_KIND_LIFECYCLE: '1'
BRIDGE_TEST_KUBECONFIG: ${{ runner.temp }}/bridge-addon-kubeconfig
run: npm test -- tests/chart-lifecycle.test.ts
- name: Exercise guarded witness enable/off/removal without claiming kernel coverage
env:
WITNESS_TEST_KIND_LIFECYCLE: '1'
WITNESS_TEST_KUBECONFIG: ${{ runner.temp }}/bridge-addon-kubeconfig
run: PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s deploy/ebpf-witness/tests -p test_kind_lifecycle.py

dependencies:
name: Dependency audit (${{ matrix.project }})
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,11 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1
- run: helm lint deploy/helm/kars
- name: Optional witness runtime, package and ownership contracts (no cluster)
run: |
python3 -m pip install -r deploy/ebpf-witness/tests/requirements.txt
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s deploy/ebpf-witness/aggregator
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s deploy/ebpf-witness/tests -p test_chart.py
- run: python3 tools/private-consumption-bundle.py --check
- name: Preserve task admission defaults with reused legacy values
run: python3 ci/helm-task-floor-compat.py
Expand Down
16 changes: 16 additions & 0 deletions bridge/bff/src/kars/cluster/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ use k8s_openapi::api::core::v1::ConfigMap;
use kube::api::{Api, ListParams};

impl Cluster {
/// Fixed, read-only optional witness inputs. Preserve 404 versus empty data
/// versus API failure, and retain object identity for publisher validation.
pub async fn datapath_witness_configmap(
&self,
settings: bool,
) -> Result<Option<ConfigMap>, kube::Error> {
let name = if settings {
"kars-datapath-witness-settings"
} else {
"kars-datapath-witness"
};
Api::<ConfigMap>::namespaced(self.client.clone(), "kars-system")
.get_opt(name)
.await
}

/// Read the operator-curated MCP profiles (named vetted server bundles),
/// stored as `profiles.json` in the `kars-mcp-profiles` ConfigMap. Returns
/// `[]` when unset. A profile is `{name, summary, servers:[mcpserver names]}`.
Expand Down
129 changes: 4 additions & 125 deletions bridge/bff/src/routes/operator/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
use axum::Json;
use axum::extract::State;
use kube::core::DynamicObject;
use serde::{Deserialize, Serialize};
use serde::Serialize;

use crate::error::{AppError, AppResult};
use crate::state::AppState;
Expand Down Expand Up @@ -155,90 +155,9 @@ pub async fn get_audit(State(state): State<AppState>) -> AppResult<Json<AuditDto
}))
}

// ─── datapath-completeness witness (optional eBPF) ───────────────────────────
//
// An independent, kernel-level attestation of what sandboxes ACTUALLY send on
// the network, cross-checked against the controller-declared egress allowlist.
// Produced out-of-band by the optional Inspektor Gadget witness
// (deploy/ebpf-witness/) and published to the `kars-datapath-witness` ConfigMap
// in kars-system. The Bridge only READS that ConfigMap — no eBPF/gadget
// dependency here. Absent ConfigMap => witness not enabled (honest empty), never
// an error.

#[derive(Serialize, Deserialize, Default)]
pub struct DatapathWitnessSandbox {
pub namespace: String,
pub sandbox: String,
#[serde(default)]
pub declared_hosts: Vec<String>,
#[serde(default)]
pub observed_dns: Vec<String>,
#[serde(default)]
pub observed_connects: u64,
#[serde(default)]
pub beyond_declared: Vec<String>,
#[serde(default)]
pub unused_declared: Vec<String>,
pub verdict: String,
}

#[derive(Serialize)]
pub struct DatapathWitnessDto {
/// True once the optional eBPF witness is installed and has published a
/// verdict. False => not enabled (the web layer shows enable instructions).
pub enabled: bool,
pub generated_at: Option<String>,
pub window_seconds: Option<u32>,
pub sandboxes: Vec<DatapathWitnessSandbox>,
/// How to turn the witness on — surfaced verbatim in the not-enabled state.
pub install_hint: String,
}

#[derive(Deserialize)]
struct WitnessDoc {
generated_at: Option<String>,
window_seconds: Option<u32>,
#[serde(default)]
sandboxes: Vec<DatapathWitnessSandbox>,
}

pub async fn datapath_witness(
State(state): State<AppState>,
) -> AppResult<Json<DatapathWitnessDto>> {
let cluster = require_cluster(&state)?;
let hint = "Enable the optional eBPF datapath witness on the cluster: \
KARS_EBPF_WITNESS=1 deploy/ebpf-witness/install.sh --continuous"
.to_string();

let not_enabled = || DatapathWitnessDto {
enabled: false,
generated_at: None,
window_seconds: None,
sandboxes: Vec::new(),
install_hint: hint.clone(),
};

let Some(body) = cluster
.configmap_data("kars-datapath-witness")
.await
.and_then(|d| d.get("witness.json").cloned())
else {
return Ok(Json(not_enabled()));
};

match serde_json::from_str::<WitnessDoc>(&body) {
Ok(doc) => Ok(Json(DatapathWitnessDto {
enabled: true,
generated_at: doc.generated_at,
window_seconds: doc.window_seconds,
sandboxes: doc.sandboxes,
install_hint: hint,
})),
// Malformed payload is treated as not-enabled rather than a hard error —
// the console must never 500 on optional-feature data.
Err(_) => Ok(Json(not_enabled())),
}
}
#[path = "datapath.rs"]
mod datapath;
pub use datapath::datapath_witness;

#[cfg(test)]
mod tests {
Expand Down Expand Up @@ -285,44 +204,4 @@ mod tests {
// No claims ⇒ "none".
assert_eq!(receipt_verdict(&[]), "none");
}

#[test]
fn witness_doc_parses_real_aggregator_payload() {
// The exact shape the aggregator publishes into kars-datapath-witness.
let body = r#"{
"generated_at": "2026-07-02T13:51:32Z",
"window_seconds": 15,
"gadget": "inspektor-gadget",
"sandboxes": [
{"namespace":"kars-demo","sandbox":"demo",
"declared_hosts":["api.github.com"],
"observed_dns":["api.github.com","example.com"],
"observed_connects":4,
"beyond_declared":["example.com"],
"unused_declared":[],
"verdict":"BEYOND-DECLARED"}
]
}"#;
let doc: super::WitnessDoc = serde_json::from_str(body).expect("parse");
assert_eq!(doc.generated_at.as_deref(), Some("2026-07-02T13:51:32Z"));
assert_eq!(doc.window_seconds, Some(15));
assert_eq!(doc.sandboxes.len(), 1);
let s = &doc.sandboxes[0];
assert_eq!(s.sandbox, "demo");
assert_eq!(s.verdict, "BEYOND-DECLARED");
assert_eq!(s.beyond_declared, vec!["example.com"]);
assert_eq!(s.observed_connects, 4);
}

#[test]
fn witness_sandbox_tolerates_missing_optional_arrays() {
// Defaults must hold so a partial payload never fails deserialization.
let s: super::DatapathWitnessSandbox =
serde_json::from_str(r#"{"namespace":"n","sandbox":"x","verdict":"LEARN"}"#)
.expect("parse");
assert_eq!(s.verdict, "LEARN");
assert!(s.declared_hosts.is_empty());
assert!(s.observed_dns.is_empty());
assert_eq!(s.observed_connects, 0);
}
}
Loading
Loading