mirror of
https://github.com/kubeshark/kubeshark.git
synced 2026-06-16 13:17:45 +00:00
Compare commits
7 Commits
security-a
...
cli-sa-aut
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7fe7f4ce5 | ||
|
|
dba8867fb5 | ||
|
|
1c3e99d823 | ||
|
|
7c67abc3fd | ||
|
|
6ba67ef3f5 | ||
|
|
9396e64b9b | ||
|
|
b5e59321e0 |
24
.github/workflows/release-tag.yml
vendored
24
.github/workflows/release-tag.yml
vendored
@@ -1,24 +0,0 @@
|
||||
name: Auto-tag release
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
tag:
|
||||
if: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create and push tag
|
||||
run: |
|
||||
VERSION="${GITHUB_HEAD_REF#release/}"
|
||||
echo "Creating tag $VERSION on master"
|
||||
git tag "$VERSION"
|
||||
git push origin "$VERSION"
|
||||
12
Makefile
12
Makefile
@@ -268,8 +268,8 @@ release: ## Print release workflow instructions.
|
||||
@echo ""
|
||||
@echo " Shortcut: make release-pr VERSION=x.y.z runs 1 → 2 → 3."
|
||||
@echo ""
|
||||
@echo " After both PRs merge: tag is created automatically,"
|
||||
@echo " or run: make release-tag VERSION=x.y.z"
|
||||
@echo " After both PRs merge, create the release tag:"
|
||||
@echo " make release-tag VERSION=x.y.z"
|
||||
|
||||
# Internal: validate VERSION before any release-* target runs.
|
||||
_release-check-version:
|
||||
@@ -362,14 +362,18 @@ release-pr: release-siblings release-pr-kubeshark release-pr-helm ## Run release
|
||||
@echo " - kubeshark.github.io: Review and merge the helm chart PR."
|
||||
@echo "Tag will be created automatically, or run: make release-tag VERSION=$(VERSION)"
|
||||
|
||||
release-tag: ## Step 2 (fallback): Tag master after release PR is merged.
|
||||
release-tag: _release-check-version ## Step 2: Tag master after release PR is merged. Idempotent; re-run to retrigger the release build.
|
||||
@echo "Verifying release PR was merged..."
|
||||
@if ! gh pr list --state merged --head release/v$(VERSION) --json number --jq '.[0].number' | grep -q .; then \
|
||||
echo "Error: No merged PR found for release/v$(VERSION). Merge the PR first."; \
|
||||
exit 1; \
|
||||
fi
|
||||
@git checkout master && git pull
|
||||
@git tag -d v$(VERSION) 2>/dev/null; git tag v$(VERSION) && git push origin --tags
|
||||
@if git ls-remote --tags origin "refs/tags/v$(VERSION)" | grep -q .; then \
|
||||
echo "Tag v$(VERSION) already exists on origin — deleting to retrigger release..."; \
|
||||
git push origin :refs/tags/v$(VERSION); \
|
||||
fi
|
||||
@git tag -d v$(VERSION) 2>/dev/null; git tag v$(VERSION) && git push origin "refs/tags/v$(VERSION)"
|
||||
@echo ""
|
||||
@echo "Tagged v$(VERSION) on master. GitHub Actions will build the release."
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -44,6 +45,18 @@ func init() {
|
||||
func runConsoleWithoutProxy() {
|
||||
log.Info().Msg("Starting scripting console ...")
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
// Best-effort: mint a scoped ServiceAccount token to authenticate to a
|
||||
// gated Hub as kubeshark-cli; fall back to License-Key when not possible.
|
||||
saToken := ""
|
||||
if provider, err := getKubernetesProviderForCli(true, true); err == nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
if tok, terr := provider.MintHubToken(ctx, config.Config.Tap.Release.Namespace); terr == nil {
|
||||
saToken = tok
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
|
||||
hubUrl := kubernetes.GetHubUrl()
|
||||
for {
|
||||
|
||||
@@ -66,7 +79,11 @@ func runConsoleWithoutProxy() {
|
||||
}
|
||||
headers := http.Header{}
|
||||
headers.Set(utils.X_KUBESHARK_CAPTURE_HEADER_KEY, utils.X_KUBESHARK_CAPTURE_HEADER_IGNORE_VALUE)
|
||||
headers.Set("License-Key", config.Config.License)
|
||||
if saToken != "" {
|
||||
headers.Set(utils.CLI_AUTH_HEADER, saToken)
|
||||
} else {
|
||||
headers.Set(utils.LICENSE_KEY_HEADER, config.Config.License)
|
||||
}
|
||||
|
||||
c, _, err := websocket.DefaultDialer.Dial(u.String(), headers)
|
||||
if err != nil {
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/kubeshark/kubeshark/internal/connect"
|
||||
"github.com/kubeshark/kubeshark/kubernetes"
|
||||
"github.com/kubeshark/kubeshark/misc"
|
||||
"github.com/kubeshark/kubeshark/utils"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
@@ -158,6 +159,7 @@ type mcpServer struct {
|
||||
cachedHubMCP *hubMCPResponse // Cached tools/prompts from Hub
|
||||
cachedAt time.Time // When the cache was populated
|
||||
hubMCPMu sync.Mutex
|
||||
saToken string // ServiceAccount token for a gated Hub (phase 2a); empty falls back to License-Key
|
||||
}
|
||||
|
||||
const hubMCPCacheTTL = 5 * time.Minute
|
||||
@@ -166,13 +168,30 @@ func runMCPWithConfig(setFlags []string, directURL string, allowDestructive bool
|
||||
// Disable zerolog output to stderr (MCP uses stdio)
|
||||
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||
|
||||
urlMode := directURL != ""
|
||||
|
||||
// Best-effort: mint a scoped ServiceAccount token so the CLI authenticates
|
||||
// to a gated Hub as kubeshark-cli. Falls back to License-Key when minting
|
||||
// isn't possible (no kube access in --url mode, SA missing, or no RBAC).
|
||||
saToken := ""
|
||||
if !urlMode {
|
||||
if provider, err := getKubernetesProviderForCli(true, true); err == nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
if tok, terr := provider.MintHubToken(ctx, config.Config.Tap.Release.Namespace); terr == nil {
|
||||
saToken = tok
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
server := &mcpServer{
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
httpClient: utils.NewHubHTTPClientWithToken(30*time.Second, saToken, config.Config.License),
|
||||
saToken: saToken,
|
||||
stdin: os.Stdin,
|
||||
stdout: os.Stdout,
|
||||
setFlags: setFlags,
|
||||
directURL: directURL,
|
||||
urlMode: directURL != "",
|
||||
urlMode: urlMode,
|
||||
allowDestructive: allowDestructive,
|
||||
}
|
||||
|
||||
@@ -195,7 +214,7 @@ func (s *mcpServer) validateDirectURL() error {
|
||||
s.directURL = urlStr
|
||||
|
||||
// Use a short timeout for validation
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
client := utils.NewHubHTTPClientWithToken(10*time.Second, s.saToken, config.Config.License)
|
||||
|
||||
// Try to reach the MCP API base endpoint which returns tool definitions
|
||||
testURL := fmt.Sprintf("%s/api/mcp", urlStr)
|
||||
@@ -205,6 +224,10 @@ func (s *mcpServer) validateDirectURL() error {
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if utils.IsAuthRequired(resp) {
|
||||
return utils.ErrHubAuthRequired
|
||||
}
|
||||
|
||||
// Try to parse the MCP response to validate it's a valid Kubeshark endpoint
|
||||
var mcpInfo hubMCPResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&mcpInfo); err != nil {
|
||||
@@ -820,10 +843,10 @@ func (s *mcpServer) callDownloadFile(args map[string]any) (string, bool) {
|
||||
// The default s.httpClient has a 30s total timeout which would fail for large files (up to 10GB).
|
||||
// This client sets only connection-level timeouts and lets the body stream without a deadline.
|
||||
downloadClient := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Transport: utils.HubAuthTransportWithToken(s.saToken, config.Config.License, &http.Transport{
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ResponseHeaderTimeout: 30 * time.Second,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
resp, err := downloadClient.Get(fullURL)
|
||||
@@ -1145,7 +1168,7 @@ func establishProxyConnection(timeout time.Duration) (string, error) {
|
||||
|
||||
// fetchAndDisplayTools fetches tools from the Kubeshark API and displays them
|
||||
func fetchAndDisplayTools(hubURL string, timeout time.Duration) {
|
||||
client := &http.Client{Timeout: timeout}
|
||||
client := utils.NewHubHTTPClient(timeout, config.Config.License)
|
||||
|
||||
// Fetch tools list from /api/mcp endpoint
|
||||
resp, err := client.Get(strings.TrimSuffix(hubURL, "/mcp") + "/mcp")
|
||||
|
||||
@@ -30,8 +30,10 @@ data:
|
||||
{{- end }}'
|
||||
AUTH_SAML_IDP_METADATA_URL: '{{ .Values.tap.auth.saml.idpMetadataUrl }}'
|
||||
AUTH_ROLES: '{{ .Values.tap.auth.roles | toJson }}'
|
||||
AUTH_CLI_SERVICE_ACCOUNTS: '{{ if (((.Values.tap).auth).cli).enabled }}{{ .Release.Namespace }}:kubeshark-cli{{ end }}'
|
||||
AUTH_ROLES_CLAIM: '{{ .Values.tap.auth.rolesClaim }}'
|
||||
AUTH_DEFAULT_ROLE: '{{ default "" .Values.tap.auth.defaultRole }}'
|
||||
AUTH_GROUP_MAPPING: '{{ .Values.tap.auth.groupMapping | default dict | toJson }}'
|
||||
AUTH_OIDC_ISSUER: '{{ default "not set" (((.Values.tap).auth).oidc).issuer }}'
|
||||
AUTH_OIDC_REFRESH_TOKEN_LIFETIME: '{{ default "3960h" (((.Values.tap).auth).oidc).refreshTokenLifetime }}'
|
||||
AUTH_OIDC_STATE_PARAM_EXPIRY: '{{ default "10m" (((.Values.tap).auth).oidc).oauth2StateParamExpiry }}'
|
||||
|
||||
44
helm-chart/templates/22-cli-auth.yaml
Normal file
44
helm-chart/templates/22-cli-auth.yaml
Normal file
@@ -0,0 +1,44 @@
|
||||
{{- if (((.Values.tap).auth).cli).enabled }}
|
||||
---
|
||||
# ServiceAccount the CLI mints a short-lived token for (TokenRequest API) to
|
||||
# authenticate to a gated Hub. Its name must match the Hub's
|
||||
# AUTH_CLI_SERVICE_ACCOUNTS allowlist and the CLI's kubeshark-cli constant.
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "kubeshark.labels" . | nindent 4 }}
|
||||
name: kubeshark-cli
|
||||
namespace: {{ .Release.Namespace }}
|
||||
---
|
||||
# Permission to mint a token for the kubeshark-cli SA. Binding this Role to a
|
||||
# subject is what grants that subject CLI access to a gated Hub.
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "kubeshark.labels" . | nindent 4 }}
|
||||
name: kubeshark-cli-token-minter
|
||||
namespace: {{ .Release.Namespace }}
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["serviceaccounts/token"]
|
||||
resourceNames: ["kubeshark-cli"]
|
||||
verbs: ["create"]
|
||||
{{- with .Values.tap.auth.cli.subjects }}
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "kubeshark.labels" $ | nindent 4 }}
|
||||
name: kubeshark-cli-token-minter
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: kubeshark-cli-token-minter
|
||||
subjects:
|
||||
{{- toYaml . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -169,6 +169,21 @@ tap:
|
||||
rolesClaim: role
|
||||
defaultRole: ""
|
||||
defaultFilter: ""
|
||||
# CLI ServiceAccount-token auth (gated-auth phase 2a). When enabled, the
|
||||
# chart creates a `kubeshark-cli` ServiceAccount and a Role permitting
|
||||
# `create` on its token, and the Hub allowlists it via
|
||||
# AUTH_CLI_SERVICE_ACCOUNTS. The CLI mints a short-lived token for that SA
|
||||
# to authenticate to a gated Hub. Map `kubeshark-cli` to a role via
|
||||
# `groupMapping` (or `defaultRole`); without a mapping it falls back to
|
||||
# `defaultRole`.
|
||||
cli:
|
||||
enabled: false
|
||||
# RBAC subjects allowed to mint the kubeshark-cli token — i.e. who may
|
||||
# use the CLI against a gated Hub. Example:
|
||||
# - kind: User
|
||||
# name: alice@example.com
|
||||
# apiGroup: rbac.authorization.k8s.io
|
||||
subjects: []
|
||||
saml:
|
||||
idpMetadataUrl: ""
|
||||
x509crt: ""
|
||||
|
||||
44
kubernetes/hubtoken.go
Normal file
44
kubernetes/hubtoken.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
authenticationv1 "k8s.io/api/authentication/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
// HubTokenAudience must match the hub's internalauth TokenAudience.
|
||||
HubTokenAudience = "kubeshark-hub"
|
||||
// CLIServiceAccountName is the ServiceAccount the CLI mints a token for;
|
||||
// must match the SA the helm chart creates and the hub's
|
||||
// AUTH_CLI_SERVICE_ACCOUNTS allowlist.
|
||||
CLIServiceAccountName = "kubeshark-cli"
|
||||
|
||||
hubTokenExpirySeconds = 3600
|
||||
)
|
||||
|
||||
// MintHubToken requests a short-lived ServiceAccount token (audience
|
||||
// HubTokenAudience) for the CLI ServiceAccount via the TokenRequest API. The
|
||||
// hub validates it with TokenReview and maps the SA to a role. The caller's
|
||||
// kube RBAC must permit `create` on serviceaccounts/token for that SA — which
|
||||
// is what gates who may use the CLI against a gated Hub.
|
||||
func (provider *Provider) MintHubToken(ctx context.Context, namespace string) (string, error) {
|
||||
exp := int64(hubTokenExpirySeconds)
|
||||
tr, err := provider.clientSet.CoreV1().ServiceAccounts(namespace).CreateToken(
|
||||
ctx,
|
||||
CLIServiceAccountName,
|
||||
&authenticationv1.TokenRequest{
|
||||
Spec: authenticationv1.TokenRequestSpec{
|
||||
Audiences: []string{HubTokenAudience},
|
||||
ExpirationSeconds: &exp,
|
||||
},
|
||||
},
|
||||
metav1.CreateOptions{},
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("minting hub token for serviceaccount %s/%s: %w", namespace, CLIServiceAccountName, err)
|
||||
}
|
||||
return tr.Status.Token, nil
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
# Security Audit Skill
|
||||
|
||||
A Kubeshark MCP skill that teaches AI agents to perform systematic Kubernetes
|
||||
network security audits using the MITRE ATT&CK framework. It examines DNS
|
||||
queries, HTTP requests, L4 flows, and protocol-level payloads to detect
|
||||
compromised workloads, C2 communication, data exfiltration, cryptomining,
|
||||
lateral movement, and credential theft.
|
||||
|
||||
See [SKILL.md](SKILL.md) for the full methodology.
|
||||
|
||||
## Demo
|
||||
|
||||
The demo below shows a real security audit session against a compromised
|
||||
`k8s-mule` namespace containing 21 workloads, 6 of which were actively
|
||||
compromised with C2, cryptomining, secret theft, S3 exfiltration, port
|
||||
scanning, and Redis reconnaissance.
|
||||
|
||||
### Claude Code Session
|
||||
|
||||
<!-- TODO: replace with animated GIF once recorded -->
|
||||

|
||||
|
||||
### Sample Audit Report
|
||||
|
||||
<!-- TODO: replace with animated GIF once recorded -->
|
||||

|
||||
@@ -113,25 +113,15 @@ waiting for snapshot creation or dissection.
|
||||
|
||||
Confirm Kubeshark is running and which tools are available.
|
||||
|
||||
**Tool**: `get_data_boundaries`
|
||||
|
||||
Check how far back raw capture data exists. You need this to plan snapshot
|
||||
creation in Step 3 — call it now so the data is ready when you need it.
|
||||
|
||||
**Tool**: `list_workloads` (no snapshot_id — queries live state)
|
||||
|
||||
Get the current workload inventory for the target namespace. This returns
|
||||
pod names, namespaces, and IP addresses. Save the IPs — you'll need them
|
||||
throughout the audit.
|
||||
|
||||
**Note**: `list_workloads` without a `snapshot_id` may fail with some
|
||||
Kubeshark versions (`snapshot_id is required for filtered listing`). If
|
||||
this happens, use individual lookups with `name` + `namespace` parameters,
|
||||
or skip to Step 3 and get the workload inventory from the first snapshot.
|
||||
|
||||
### Step 2: Query Live Traffic
|
||||
|
||||
In parallel, query the real-time dissected traffic across key dimensions.
|
||||
**Tool**: `get_l7_data_boundaries`
|
||||
|
||||
Check the time boundaries of dissected API calls in the real-time database.
|
||||
This tells you how far back L7 data is available — use it to understand
|
||||
the scope of your real-time queries before running them.
|
||||
|
||||
Then query the real-time dissected traffic across key dimensions.
|
||||
Use `list_api_calls` and `list_l4_flows` **without** a `snapshot_id` to
|
||||
hit the live data.
|
||||
|
||||
@@ -155,6 +145,12 @@ appear. Treat Section A findings as a fast first pass, not the final word.
|
||||
|
||||
While analyzing real-time data, begin creating snapshots for Section B.
|
||||
|
||||
**Tool**: `get_data_boundaries`
|
||||
|
||||
Check how far back raw capture data exists. Raw capture is the FIFO buffer
|
||||
that feeds snapshot creation — this tells you the time window available
|
||||
for snapshots (which is different from the L7 boundaries in Step 2).
|
||||
|
||||
**CRITICAL: Create snapshots ONE AT A TIME, sequentially.** Kubeshark only
|
||||
supports one concurrent snapshot download. Parallel creation will cause
|
||||
failures and data loss. The pattern is:
|
||||
@@ -164,8 +160,7 @@ failures and data loss. The pattern is:
|
||||
3. You do NOT need to wait for dissection before creating the next snapshot.
|
||||
Create the next snapshot while the previous one dissects.
|
||||
|
||||
Use the data boundaries from Step 1 (`get_data_boundaries`) to calculate
|
||||
how many snapshots are needed:
|
||||
Use `get_data_boundaries` to calculate how many snapshots are needed:
|
||||
|
||||
```
|
||||
total_range_ms = newest_timestamp - oldest_timestamp
|
||||
@@ -247,7 +242,6 @@ wait for dissection to use the first two:
|
||||
| Source | Available | Tool | What It Provides |
|
||||
|--------|-----------|------|-----------------|
|
||||
| **Workloads & IPs** | Immediately | `list_workloads` with `snapshot_id` | Pod names, namespaces, IPs at capture time |
|
||||
| **L4 Flows** | Immediately | `list_l4_flows` with `snapshot_id` | TCP/UDP connections: src/dst IPs, ports, bytes, duration |
|
||||
| **PCAP Export** | Immediately | `export_snapshot_pcap` | Raw packets filtered by BPF expression |
|
||||
| **L7 Dissection** | After indexing | `list_api_calls`, `get_api_call`, `get_api_stats` | DNS queries, HTTP requests, SQL statements, Redis commands, gRPC methods |
|
||||
|
||||
@@ -261,12 +255,12 @@ Snapshot ready
|
||||
├── Start dissection (background)
|
||||
├── Phase 1: list_workloads (immediate) — workload inventory + IPs
|
||||
│ export_snapshot_pcap (immediate) — raw packet evidence
|
||||
├── Phase 3: list_l4_flows (immediate) — external flows, port scanning
|
||||
├── Phase 4: list_l4_flows (immediate) — lateral movement, fan-out
|
||||
│
|
||||
├── [dissection completes]
|
||||
│
|
||||
├── Phase 2: list_api_calls — DNS threat analysis
|
||||
├── Phase 3: list_api_calls — external HTTP communication
|
||||
├── Phase 4: list_api_calls — lateral movement, K8s API access
|
||||
├── Phase 5: list_api_calls — protocol abuse (PG, Redis, gRPC)
|
||||
├── Phase 6: list_api_calls — credential access (IMDS, cloud APIs)
|
||||
└── Phase 7: correlate all findings
|
||||
@@ -396,32 +390,14 @@ Compare the count of failed queries to total queries per source pod.
|
||||
|
||||
**Goal**: Identify all traffic leaving the cluster. Any pod connecting to
|
||||
external IPs or domains needs justification.
|
||||
**Data source**: Immediate (no dissection needed). Use L4 flows first,
|
||||
then enrich with L7 data from dissection when available.
|
||||
**Data source**: L7 dissection (after indexing).
|
||||
|
||||
### 3a: L4 External Flows
|
||||
**Note**: L4 flow analysis for external communication is covered in
|
||||
Section A (Step 2) using `list_l4_flows` against real-time data. In
|
||||
Section B, use `list_api_calls` against dissected snapshot data for
|
||||
deeper L7 inspection of external traffic.
|
||||
|
||||
**Tool**: `list_l4_flows` with `snapshot_id`
|
||||
|
||||
This is available immediately — do not wait for dissection. Use the workload
|
||||
IPs from Phase 1 to map flows to pod identities.
|
||||
|
||||
Look for flows where the destination is NOT a cluster-internal IP (not RFC 1918:
|
||||
10.x.x.x, 172.16-31.x.x, 192.168.x.x). Every external flow is a potential
|
||||
exfiltration or C2 channel.
|
||||
|
||||
**What to flag**:
|
||||
|
||||
| Pattern | Threat | Severity |
|
||||
|---------|--------|----------|
|
||||
| Destination 169.254.169.254 | IMDS metadata credential theft | CRITICAL |
|
||||
| Destination port 3333, 14433, 45700 | Stratum mining protocol | CRITICAL |
|
||||
| Destination port 4444, 1337 | Reverse shell / backdoor | CRITICAL |
|
||||
| Persistent connections to single external IP | C2 beaconing | HIGH |
|
||||
| Large outbound data volume (>1MB) to external | Data exfiltration | HIGH |
|
||||
| Connections to cloud API endpoints (port 443) | Stolen credential usage | MEDIUM |
|
||||
|
||||
### 3b: HTTP External Requests
|
||||
### 3a: HTTP External Requests
|
||||
|
||||
**Tool**: `list_api_calls` with KFL: `http && !dst.pod.namespace.startsWith("kube")`
|
||||
|
||||
@@ -440,8 +416,11 @@ Inspect outbound HTTP requests for:
|
||||
|
||||
**Goal**: Identify pods communicating with services they shouldn't — crossing
|
||||
namespace boundaries, probing infrastructure, or scanning the network.
|
||||
**Data source**: L4 flows (immediate) for port scanning detection. L7
|
||||
dissection (after indexing) for cross-namespace HTTP and API server analysis.
|
||||
**Data source**: L7 dissection (after indexing) for cross-namespace HTTP
|
||||
and API server analysis.
|
||||
|
||||
**Note**: Port scanning detection via `list_l4_flows` is covered in
|
||||
Section A (Step 2) against real-time data.
|
||||
|
||||
### 4a: Cross-Namespace Traffic
|
||||
|
||||
@@ -468,19 +447,7 @@ A pod hitting **multiple** of these paths is performing systematic enumeration,
|
||||
not legitimate API access. Legitimate workloads typically access 1-2 specific
|
||||
resources, not sweep across resource types.
|
||||
|
||||
### 4c: Port Scanning Detection
|
||||
|
||||
**Tool**: `list_l4_flows` with `snapshot_id` (immediate — no dissection needed)
|
||||
|
||||
Use the workload IPs from Phase 1 to identify the source pod.
|
||||
Look for a single source IP with connections to:
|
||||
- Many distinct destination IPs (>10)
|
||||
- Many distinct destination ports (>5)
|
||||
- High connection failure rate (RST/timeout)
|
||||
|
||||
This is a textbook port scan pattern.
|
||||
|
||||
### 4d: Service Fingerprinting
|
||||
### 4c: Service Fingerprinting
|
||||
|
||||
**Tool**: `list_api_calls` with KFL: `http && (path == "/.env" || path == "/actuator/info" || path == "/server-info" || path == "/version")`
|
||||
|
||||
@@ -488,7 +455,7 @@ These paths are used for service fingerprinting — mapping what software is
|
||||
running on internal endpoints. A pod probing multiple services with these
|
||||
paths is performing reconnaissance.
|
||||
|
||||
### 4e: Service Account Permission Audit via Traffic
|
||||
### 4d: Service Account Permission Audit via Traffic
|
||||
|
||||
Cross-reference Phase 4b findings (K8s API traffic) with the source pod's
|
||||
actual service account to determine if permissions are excessive.
|
||||
@@ -515,7 +482,7 @@ For each pod making API server calls:
|
||||
This converts a network finding (API traffic volume) into an actionable RBAC
|
||||
recommendation — telling the user exactly which ClusterRoleBinding to revoke.
|
||||
|
||||
### 4f: Cross-Namespace Threat Correlation
|
||||
### 4e: Cross-Namespace Threat Correlation
|
||||
|
||||
When port scanning or lateral movement targets IPs outside the audited
|
||||
namespace (e.g., IPs in the pod CIDR `10.244.x.x` that don't belong to
|
||||
@@ -603,8 +570,6 @@ cloud API exploitation.
|
||||
|
||||
**Tool**: `list_api_calls` with KFL: `dst.ip == "169.254.169.254"`
|
||||
|
||||
Or use `list_l4_flows` to find connections to 169.254.169.254.
|
||||
|
||||
Any pod connecting to this IP is attempting to steal the node's cloud credentials.
|
||||
Check the HTTP paths:
|
||||
|
||||
|
||||
@@ -2,17 +2,97 @@ package utils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
X_KUBESHARK_CAPTURE_HEADER_KEY = "X-Kubeshark-Capture"
|
||||
X_KUBESHARK_CAPTURE_HEADER_IGNORE_VALUE = "ignore"
|
||||
LICENSE_KEY_HEADER = "License-Key"
|
||||
// CLI_AUTH_HEADER carries a ServiceAccount bearer token to the Hub.
|
||||
// A custom (non-Authorization) header so it survives the kube
|
||||
// API-server service proxy, which consumes Authorization.
|
||||
CLI_AUTH_HEADER = "X-Kubeshark-Authorization"
|
||||
)
|
||||
|
||||
// ErrHubAuthRequired indicates the Hub rejected the request because auth is
|
||||
// enabled but no valid credential was presented (missing/expired license).
|
||||
var ErrHubAuthRequired = errors.New("hub requires authentication: set a valid license (config 'license') or credentials")
|
||||
|
||||
// hubAuthRoundTripper attaches the CLI's Hub credential to every request so any
|
||||
// client built with it authenticates to a gated Hub. It prefers a scoped
|
||||
// ServiceAccount token (CLI_AUTH_HEADER) when present, falling back to the
|
||||
// License-Key (admin/transitional). Both are custom headers so they survive
|
||||
// the kube API-server service proxy, unlike an Authorization bearer.
|
||||
type hubAuthRoundTripper struct {
|
||||
saToken string
|
||||
licenseKey string
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (rt *hubAuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
base := rt.base
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
switch {
|
||||
case rt.saToken != "":
|
||||
if req.Header.Get(CLI_AUTH_HEADER) == "" {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set(CLI_AUTH_HEADER, rt.saToken)
|
||||
}
|
||||
case rt.licenseKey != "":
|
||||
if req.Header.Get(LICENSE_KEY_HEADER) == "" {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set(LICENSE_KEY_HEADER, rt.licenseKey)
|
||||
}
|
||||
}
|
||||
return base.RoundTrip(req)
|
||||
}
|
||||
|
||||
// NewHubHTTPClient returns an *http.Client that authenticates to the Hub with
|
||||
// the License-Key header (Phase 1).
|
||||
func NewHubHTTPClient(timeout time.Duration, licenseKey string) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &hubAuthRoundTripper{licenseKey: licenseKey},
|
||||
}
|
||||
}
|
||||
|
||||
// NewHubHTTPClientWithToken returns an *http.Client that authenticates to the
|
||||
// Hub with a ServiceAccount token when saToken is set, otherwise the
|
||||
// License-Key.
|
||||
func NewHubHTTPClientWithToken(timeout time.Duration, saToken, licenseKey string) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &hubAuthRoundTripper{saToken: saToken, licenseKey: licenseKey},
|
||||
}
|
||||
}
|
||||
|
||||
// HubAuthTransport wraps base so requests carry the License-Key header. Use
|
||||
// when a client needs custom transport settings (e.g. streaming downloads)
|
||||
// but must still authenticate to the Hub.
|
||||
func HubAuthTransport(licenseKey string, base http.RoundTripper) http.RoundTripper {
|
||||
return &hubAuthRoundTripper{licenseKey: licenseKey, base: base}
|
||||
}
|
||||
|
||||
// HubAuthTransportWithToken is HubAuthTransport with a ServiceAccount token
|
||||
// (preferred over the License-Key when set).
|
||||
func HubAuthTransportWithToken(saToken, licenseKey string, base http.RoundTripper) http.RoundTripper {
|
||||
return &hubAuthRoundTripper{saToken: saToken, licenseKey: licenseKey, base: base}
|
||||
}
|
||||
|
||||
// IsAuthRequired reports whether the response indicates the Hub demanded
|
||||
// authentication — a 401, or a 302 redirect to an SSO login page.
|
||||
func IsAuthRequired(resp *http.Response) bool {
|
||||
return resp != nil && (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusFound)
|
||||
}
|
||||
|
||||
// Get - When err is nil, resp always contains a non-nil resp.Body.
|
||||
// Caller should close resp.Body when done reading from it.
|
||||
func Get(url string, client *http.Client) (*http.Response, error) {
|
||||
|
||||
Reference in New Issue
Block a user