Compare commits

..

1 Commits

Author SHA1 Message Date
Matthias Bertschy
31552b6171 upgrading github.com/docker/buildx to v0.22.0
Signed-off-by: Matthias Bertschy <matthias.bertschy@gmail.com>
2025-03-20 13:35:30 +01:00
202 changed files with 26041 additions and 52055 deletions

View File

@@ -1,243 +1,71 @@
name: 00-pr_scanner
permissions: read-all
on:
workflow_dispatch: {}
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
paths-ignore:
- "**.yaml"
- "**.yml"
- "**.md"
- "**.sh"
- "website/*"
- "examples/*"
- "docs/*"
- "build/*"
- ".github/*"
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
paths-ignore:
- '**.yaml'
- '**.yml'
- '**.md'
- '**.sh'
- 'website/*'
- 'examples/*'
- 'docs/*'
- 'build/*'
- '.github/*'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
pr-scanner:
permissions:
actions: read
artifact-metadata: read
attestations: read
checks: read
contents: write
deployments: read
discussions: read
id-token: write
issues: read
models: read
packages: read
pages: read
pull-requests: write
repository-projects: read
security-events: read
statuses: read
uses: ./.github/workflows/a-pr-scanner.yaml
with:
RELEASE: ""
CLIENT: test
CGO_ENABLED: 0
GO111MODULE: ""
secrets: inherit
pr-scanner:
permissions:
actions: read
checks: read
deployments: read
id-token: write
issues: read
discussions: read
packages: read
pages: read
pull-requests: write
repository-projects: read
security-events: read
statuses: read
attestations: read
contents: write
uses: ./.github/workflows/a-pr-scanner.yaml
with:
RELEASE: ""
CLIENT: test
CGO_ENABLED: 0
GO111MODULE: ""
secrets: inherit
wf-preparation:
name: secret-validator
runs-on: ubuntu-latest
outputs:
is-secret-set: ${{ steps.check-secret-set.outputs.is-secret-set }}
steps:
- name: check if the necessary secrets are set in github secrets
id: check-secret-set
env:
CUSTOMER: ${{ secrets.CUSTOMER }}
USERNAME: ${{ secrets.USERNAME }}
PASSWORD: ${{ secrets.PASSWORD }}
CLIENT_ID: ${{ secrets.CLIENT_ID_PROD }}
SECRET_KEY: ${{ secrets.SECRET_KEY_PROD }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: "echo \"is-secret-set=${{ env.CUSTOMER != '' && env.USERNAME != '' && env.PASSWORD != '' && env.CLIENT_ID != '' && env.SECRET_KEY != '' && env.REGISTRY_USERNAME != '' && env.REGISTRY_PASSWORD != '' }}\" >> $GITHUB_OUTPUT\n"
run-system-tests:
needs: [wf-preparation, pr-scanner]
if: ${{ (needs.wf-preparation.outputs.is-secret-set == 'true') && (always() && (contains(needs.*.result, 'success') || contains(needs.*.result, 'skipped')) && !(contains(needs.*.result, 'failure')) && !(contains(needs.*.result, 'cancelled'))) }}
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
pull-requests: write
steps:
- name: Set dispatch info
id: dispatch-info
run: |
# Correlation ID WITHOUT attempt - so re-runs can find the original run
CORRELATION_ID="${GITHUB_REPOSITORY##*/}-${{ github.run_id }}"
echo "correlation_id=${CORRELATION_ID}" >> "$GITHUB_OUTPUT"
echo "Correlation ID: ${CORRELATION_ID}, Attempt: ${{ github.run_attempt }}"
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.E2E_DISPATCH_APP_ID }}
private-key: ${{ secrets.E2E_DISPATCH_APP_PRIVATE_KEY }}
owner: armosec
repositories: shared-workflows
- name: Dispatch system tests to private repo
if: ${{ github.run_attempt == 1 }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
CORRELATION_ID: ${{ steps.dispatch-info.outputs.correlation_id }}
KS_BRANCH: ${{ github.head_ref || github.ref_name }}
run: |
echo "Dispatching E2E tests with correlation_id: ${CORRELATION_ID}"
echo "Using test group: KUBESCAPE_CLI_E2E"
gh api "repos/armosec/shared-workflows/dispatches" \
-f event_type="e2e-test-trigger" \
-f "client_payload[correlation_id]=${CORRELATION_ID}" \
-f "client_payload[github_repository]=${GITHUB_REPOSITORY}" \
-f "client_payload[environment]=production" \
-f "client_payload[tests_groups]=KUBESCAPE_CLI_E2E" \
-f "client_payload[systests_branch]=master" \
-f "client_payload[ks_branch]=${KS_BRANCH}"
echo "Dispatch completed"
- name: Find E2E workflow run
id: find-run
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
CORRELATION_ID: ${{ steps.dispatch-info.outputs.correlation_id }}
run: |
for i in {1..15}; do
run_id=$(gh api "repos/armosec/shared-workflows/actions/runs?event=repository_dispatch&per_page=30" \
--jq '.workflow_runs | map(select(.name | contains("'"$CORRELATION_ID"'"))) | first | .id // empty')
if [ -n "$run_id" ]; then
echo "run_id=${run_id}" >> "$GITHUB_OUTPUT"
gh api "repos/armosec/shared-workflows/actions/runs/${run_id}" --jq '"url=" + .html_url' >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Attempt $i: waiting for run..."
sleep $((i < 5 ? 10 : 30))
done
echo "::error::Could not find workflow run"
exit 1
- name: Re-run failed jobs in private repo
id: rerun
if: ${{ github.run_attempt > 1 }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
RUN_ID: ${{ steps.find-run.outputs.run_id }}
run: |
conclusion=$(gh api "repos/armosec/shared-workflows/actions/runs/${RUN_ID}" --jq '.conclusion')
echo "Previous conclusion: $conclusion"
if [ "$conclusion" = "success" ]; then
echo "Previous run passed. Nothing to re-run."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Full rerun if cancelled, partial if failed
if [ "$conclusion" = "cancelled" ]; then
echo "Run was cancelled - triggering full re-run"
gh api --method POST "repos/armosec/shared-workflows/actions/runs/${RUN_ID}/rerun"
else
echo "Re-running failed jobs only"
gh api --method POST "repos/armosec/shared-workflows/actions/runs/${RUN_ID}/rerun-failed-jobs"
fi
# Wait for status to flip from 'completed'
for i in {1..30}; do
[ "$(gh api "repos/armosec/shared-workflows/actions/runs/${RUN_ID}" --jq '.status')" != "completed" ] && break
sleep 2
done
- name: Wait for E2E tests to complete
if: ${{ steps.rerun.outputs.skip != 'true' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
RUN_ID: ${{ steps.find-run.outputs.run_id }}
URL: ${{ steps.find-run.outputs.url }}
run: |
echo "Monitoring: ${URL}"
for i in {1..60}; do # 60 iterations × 60s = 1 hour max
read status conclusion < <(gh api "repos/armosec/shared-workflows/actions/runs/${RUN_ID}" \
--jq '[.status, .conclusion // "null"] | @tsv')
echo "Status: ${status} | Conclusion: ${conclusion}"
if [ "$status" = "completed" ]; then
if [ "$conclusion" = "success" ]; then
echo "E2E tests passed!"
exit 0
fi
echo "::error::E2E tests failed: ${conclusion}"
echo ""
# Get failed job IDs to a file first
gh api "repos/armosec/shared-workflows/actions/runs/${RUN_ID}/jobs" \
--jq '.jobs[] | select(.conclusion == "failure") | [.id, .name, (.steps[] | select(.conclusion == "failure") | .name)] | @tsv' > /tmp/failed_jobs.txt
# Process each failed job
while IFS=$'\t' read -r job_id job_name step_name; do
# Extract test name: "run-helm-e2e / ST (relevancy_python)" → "relevancy_python"
test_name=$(echo "$job_name" | sed 's/.*(\(.*\))/\1/')
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "${job_name}"
echo " Step: ${step_name}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Fetch logs to temp file
gh api "repos/armosec/shared-workflows/actions/jobs/${job_id}/logs" 2>/dev/null > /tmp/job_logs.txt
# Show summary in console
grep -E "(ERROR|FAILURE)" /tmp/job_logs.txt | tail -10
echo ""
# Save to separate file per test
log_file="failed_${test_name}.txt"
echo "════════════════════════════════════════" > "$log_file"
echo "${job_name}" >> "$log_file"
echo " Step: ${step_name}" >> "$log_file"
echo "════════════════════════════════════════" >> "$log_file"
last_endgroup=$(grep -n "##\\[endgroup\\]" /tmp/job_logs.txt | tail -1 | cut -d: -f1)
if [ -n "$last_endgroup" ]; then
tail -n +$((last_endgroup + 1)) /tmp/job_logs.txt >> "$log_file"
else
tail -500 /tmp/job_logs.txt >> "$log_file"
fi
done < /tmp/failed_jobs.txt
echo "View full logs: ${URL}"
exit 1
fi
sleep 60
done
echo "::error::Timeout waiting for tests"
exit 1
- name: Upload failed step logs
if: failure()
uses: actions/upload-artifact@v4
with:
name: failed-e2e-logs-attempt-${{ github.run_attempt }}
path: failed_*.txt
retention-days: 7
binary-build:
if: ${{ github.actor == 'kubescape' }}
permissions:
actions: read
checks: read
contents: write
deployments: read
discussions: read
id-token: write
issues: read
packages: write
pages: read
pull-requests: read
repository-projects: read
security-events: read
statuses: read
attestations: read
uses: ./.github/workflows/b-binary-build-and-e2e-tests.yaml
with:
COMPONENT_NAME: kubescape
CGO_ENABLED: 0
GO111MODULE: ""
GO_VERSION: "1.23"
RELEASE: "latest"
CLIENT: test
secrets: inherit

View File

@@ -3,16 +3,45 @@ permissions: read-all
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
workflow_dispatch:
inputs:
skip_publish:
description: "Skip publishing artifacts"
required: false
default: true
type: boolean
- 'v*.*.*-rc.*'
jobs:
release:
retag:
outputs:
NEW_TAG: ${{ steps.tag-calculator.outputs.NEW_TAG }}
runs-on: ubuntu22-core4-mem16-ssd150
steps:
- uses: actions/checkout@v4
- id: tag-calculator
uses: ./.github/actions/tag-action
with:
SUB_STRING: "-rc"
binary-build:
permissions:
actions: read
checks: read
deployments: read
discussions: read
id-token: write
issues: read
packages: write
pages: read
pull-requests: read
repository-projects: read
security-events: read
statuses: read
contents: write
attestations: write
needs: [retag]
uses: ./.github/workflows/b-binary-build-and-e2e-tests.yaml
with:
COMPONENT_NAME: kubescape
CGO_ENABLED: 0
GO111MODULE: ""
GO_VERSION: "1.23"
RELEASE: ${{ needs.retag.outputs.NEW_TAG }}
CLIENT: release
secrets: inherit
create-release:
permissions:
actions: read
checks: read
@@ -21,105 +50,63 @@ jobs:
discussions: read
id-token: write
issues: read
models: read
packages: write
packages: read
pages: read
pull-requests: read
repository-projects: read
statuses: read
security-events: read
attestations: read
artifact-metadata: read
runs-on: ubuntu-large
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: "1.25"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.9"
- name: Install system dependencies for system-tests
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libpq5 \
libpq-dev \
gcc \
python3-dev
sudo rm -rf /var/lib/apt/lists/*
- name: Install Cosign
uses: sigstore/cosign-installer@v3.5.0
- name: Create Cosign Key
run: echo "${{ secrets.COSIGN_PRIVATE_KEY_V1 }}" > cosign.key
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Quay.io
uses: docker/login-action@v3
with:
registry: quay.io
username: ${{ secrets.QUAYIO_REGISTRY_USERNAME }}
password: ${{ secrets.QUAYIO_REGISTRY_PASSWORD }}
- uses: anchore/sbom-action/download-syft@v0
name: Setup Syft
- name: Create k8s Kind Cluster
uses: helm/kind-action@v1.10.0
with:
cluster_name: kubescape-e2e
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
args: release --clean ${{ inputs.skip_publish == true && '--skip=publish' || '' }}
env:
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN || secrets.GITHUB_TOKEN }}
COSIGN_PWD: ${{ secrets.COSIGN_PRIVATE_KEY_V1_PASSWORD }}
RELEASE: ${{ github.ref_name }}
CLIENT: release
RUN_E2E: "true"
KUBESCAPE_SKIP_UPDATE_CHECK: "true"
CUSTOMER: ${{ secrets.CUSTOMER }}
USERNAME: ${{ secrets.USERNAME }}
PASSWORD: ${{ secrets.PASSWORD }}
CLIENT_ID: ${{ secrets.CLIENT_ID_PROD }}
SECRET_KEY: ${{ secrets.SECRET_KEY_PROD }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
- name: Update new version in krew-index
if: github.event_name != 'workflow_dispatch' || inputs.skip_publish != true
uses: rajatjindal/krew-release-bot@v0.0.47
with:
krew_template_file: .krew.yaml
- name: List collected system-test results (debug)
if: always()
run: |
echo "Listing test-results/system-tests (if any):"
ls -laR test-results/system-tests || true
- name: System Tests Report
uses: mikepenz/action-junit-report@v5
if: always()
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
report_paths: "test-results/system-tests/**/results_xml_format/**.xml"
annotate_only: true
job_summary: true
needs: [retag, binary-build]
uses: ./.github/workflows/c-create-release.yaml
with:
RELEASE_NAME: "Release ${{ needs.retag.outputs.NEW_TAG }}"
TAG: ${{ needs.retag.outputs.NEW_TAG }}
DRAFT: false
secrets: inherit
publish-image:
permissions:
actions: read
checks: read
deployments: read
discussions: read
id-token: write
issues: read
packages: write
pages: read
pull-requests: read
repository-projects: read
security-events: read
statuses: read
attestations: read
contents: write
uses: ./.github/workflows/d-publish-image.yaml
needs: [create-release, retag]
with:
client: "image-release"
image_name: "quay.io/${{ github.repository_owner }}/kubescape-cli"
image_tag: ${{ needs.retag.outputs.NEW_TAG }}
support_platforms: true
cosign: true
secrets: inherit
post-release:
permissions:
actions: read
checks: read
deployments: read
discussions: read
id-token: write
issues: read
packages: write
pages: read
pull-requests: read
repository-projects: read
security-events: read
statuses: read
attestations: read
contents: write
uses: ./.github/workflows/e-post-release.yaml
needs: [publish-image]
with:
TAG: ${{ needs.retag.outputs.NEW_TAG }}
secrets: inherit

View File

@@ -27,7 +27,7 @@ jobs:
name: Create cross-platform build
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
runs-on: ubuntu-large
runs-on: ubuntu22-core4-mem16-ssd150
steps:
- uses: actions/checkout@v4
@@ -48,7 +48,7 @@ jobs:
run: ${{ env.DOCKER_CMD }} sh -c 'cd httphandler && go test -v ./...'
if: startsWith(github.ref, 'refs/tags')
- uses: anchore/sbom-action/download-syft@v0
- uses: anchore/sbom-action/download-syft@v0.15.2
name: Setup Syft
- uses: goreleaser/goreleaser-action@v6
@@ -56,7 +56,7 @@ jobs:
with:
distribution: goreleaser
version: latest
args: build --clean --snapshot --single-target
args: release --clean --snapshot
env:
RELEASE: ${{ inputs.RELEASE }}
CLIENT: ${{ inputs.CLIENT }}
@@ -66,11 +66,86 @@ jobs:
env:
RELEASE: ${{ inputs.RELEASE }}
KUBESCAPE_SKIP_UPDATE_CHECK: "true"
run: ${{ env.DOCKER_CMD }} python3 smoke_testing/init.py ${PWD}/dist/cli_linux_amd64_v1/kubescape
run: ${{ env.DOCKER_CMD }} python3 smoke_testing/init.py ${PWD}/dist/kubescape-ubuntu-latest
- name: golangci-lint
continue-on-error: false
uses: golangci/golangci-lint-action@v9
uses: golangci/golangci-lint-action@v3
with:
version: latest
args: --timeout 10m
only-new-issues: true
skip-pkg-cache: true
skip-build-cache: true
scanners:
env:
GITGUARDIAN_API_KEY: ${{ secrets.GITGUARDIAN_API_KEY }}
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
name: PR Scanner
runs-on: ubuntu22-core4-mem16-ssd150
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- uses: actions/setup-go@v4
name: Installing go
with:
go-version: "1.23"
- name: Scanning - Forbidden Licenses (go-licenses)
id: licenses-scan
continue-on-error: true
run: |
echo "## Installing go-licenses tool"
go install github.com/google/go-licenses@latest
echo "## Scanning for forbiden licenses ##"
go-licenses check .
- name: Scanning - Credentials (GitGuardian)
if: ${{ env.GITGUARDIAN_API_KEY }}
continue-on-error: true
id: credentials-scan
uses: GitGuardian/ggshield-action@master
with:
args: -v --all-policies
env:
GITHUB_PUSH_BEFORE_SHA: ${{ github.event.before }}
GITHUB_PUSH_BASE_SHA: ${{ github.event.base }}
GITHUB_PULL_BASE_SHA: ${{ github.event.pull_request.base.sha }}
GITHUB_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GITGUARDIAN_API_KEY: ${{ secrets.GITGUARDIAN_API_KEY }}
- name: Scanning - Vulnerabilities (Snyk)
if: ${{ env.SNYK_TOKEN }}
id: vulnerabilities-scan
continue-on-error: true
uses: snyk/actions/golang@master
with:
command: test --all-projects
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
- name: Test coverage
id: unit-test
run: go test -v ${{ inputs.UNIT_TESTS_PATH }} -covermode=count -coverprofile=coverage.out
- name: Convert coverage count to lcov format
uses: jandelgado/gcov2lcov-action@v1
- name: Submit coverage tests to Coveralls
continue-on-error: true
uses: coverallsapp/github-action@v1
with:
github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}
path-to-lcov: coverage.lcov
- name: Comment results to PR
continue-on-error: true # Warning: This might break opening PRs from forks
uses: peter-evans/create-or-update-comment@v4
with:
issue-number: ${{ github.event.pull_request.number }}
body: |
Scan results:
- License scan: ${{ steps.licenses-scan.outcome }}
- Credentials scan: ${{ steps.credentials-scan.outcome }}
- Vulnerabilities scan: ${{ steps.vulnerabilities-scan.outcome }}
reactions: 'eyes'

View File

@@ -0,0 +1,359 @@
name: b-binary-build-and-e2e-tests
permissions: read-all
on:
workflow_dispatch:
inputs:
COMPONENT_NAME:
required: false
type: string
default: "kubescape"
RELEASE:
required: false
type: string
default: ""
CLIENT:
required: false
type: string
default: "test"
GO_VERSION:
required: false
type: string
default: "1.23"
GO111MODULE:
required: false
type: string
default: ""
CGO_ENABLED:
type: number
default: 1
required: false
BINARY_TESTS:
type: string
required: false
default: '[
"ks_microservice_create_2_cronjob_mitre_and_nsa_proxy",
"ks_microservice_triggering_with_cron_job",
"ks_microservice_update_cronjob_schedule",
"ks_microservice_delete_cronjob",
"ks_microservice_create_2_cronjob_mitre_and_nsa",
"ks_microservice_ns_creation",
"ks_microservice_on_demand",
"ks_microservice_mitre_framework_on_demand",
"ks_microservice_nsa_and_mitre_framework_demand",
"scan_nsa",
"scan_mitre",
"scan_with_exceptions",
"scan_repository",
"scan_local_file",
"scan_local_glob_files",
"scan_local_list_of_files",
"scan_with_exception_to_backend",
"scan_nsa_and_submit_to_backend",
"scan_mitre_and_submit_to_backend",
"scan_local_repository_and_submit_to_backend",
"scan_repository_from_url_and_submit_to_backend",
"scan_with_custom_framework",
"scan_customer_configuration",
"scan_compliance_score"
]'
workflow_call:
inputs:
COMPONENT_NAME:
required: true
type: string
RELEASE:
required: true
type: string
CLIENT:
required: true
type: string
GO_VERSION:
type: string
default: "1.23"
GO111MODULE:
required: true
type: string
CGO_ENABLED:
type: number
default: 1
BINARY_TESTS:
type: string
default: '[
"scan_nsa",
"scan_mitre",
"scan_with_exceptions",
"scan_repository",
"scan_local_file",
"scan_local_glob_files",
"scan_local_list_of_files",
"scan_nsa_and_submit_to_backend",
"scan_mitre_and_submit_to_backend",
"scan_local_repository_and_submit_to_backend",
"scan_repository_from_url_and_submit_to_backend",
"scan_with_custom_framework",
"scan_customer_configuration",
"scan_compliance_score",
"scan_custom_framework_scanning_file_scope_testing",
"scan_custom_framework_scanning_cluster_scope_testing",
"scan_custom_framework_scanning_cluster_and_file_scope_testing"
]'
jobs:
wf-preparation:
name: secret-validator
runs-on: ubuntu-latest
outputs:
TEST_NAMES: ${{ steps.export_tests_to_env.outputs.TEST_NAMES }}
is-secret-set: ${{ steps.check-secret-set.outputs.is-secret-set }}
steps:
- name: check if the necessary secrets are set in github secrets
id: check-secret-set
env:
CUSTOMER: ${{ secrets.CUSTOMER }}
USERNAME: ${{ secrets.USERNAME }}
PASSWORD: ${{ secrets.PASSWORD }}
CLIENT_ID: ${{ secrets.CLIENT_ID_PROD }}
SECRET_KEY: ${{ secrets.SECRET_KEY_PROD }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: "echo \"is-secret-set=${{ env.CUSTOMER != '' && env.USERNAME != '' && env.PASSWORD != '' && env.CLIENT_ID != '' && env.SECRET_KEY != '' && env.REGISTRY_USERNAME != '' && env.REGISTRY_PASSWORD != '' }}\" >> $GITHUB_OUTPUT\n"
- id: export_tests_to_env
name: set test name
run: |
echo "TEST_NAMES=$input" >> $GITHUB_OUTPUT
env:
input: ${{ inputs.BINARY_TESTS }}
check-secret:
name: check if QUAYIO_REGISTRY_USERNAME & QUAYIO_REGISTRY_PASSWORD is set in github secrets
runs-on: ubuntu-latest
outputs:
is-secret-set: ${{ steps.check-secret-set.outputs.is-secret-set }}
steps:
- name: check if QUAYIO_REGISTRY_USERNAME & QUAYIO_REGISTRY_PASSWORD is set in github secrets
id: check-secret-set
env:
QUAYIO_REGISTRY_USERNAME: ${{ secrets.QUAYIO_REGISTRY_USERNAME }}
QUAYIO_REGISTRY_PASSWORD: ${{ secrets.QUAYIO_REGISTRY_PASSWORD }}
run: |
echo "is-secret-set=${{ env.QUAYIO_REGISTRY_USERNAME != '' && env.QUAYIO_REGISTRY_PASSWORD != '' }}" >> $GITHUB_OUTPUT
binary-build:
name: Create cross-platform build
needs: wf-preparation
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
runs-on: ubuntu-large
steps:
- name: (debug) Step 1 - Check disk space before checkout
run: df -h
- uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- name: (debug) Step 2 - Check disk space before installing Go
run: df -h
- uses: actions/setup-go@v4
name: Installing go
with:
go-version: ${{ inputs.GO_VERSION }}
- name: (debug) Step 3 - Check disk space before build
run: df -h
- name: Test core pkg
run: ${{ env.DOCKER_CMD }} go test -v ./...
if: startsWith(github.ref, 'refs/tags')
- name: (debug) Step 4 - Check disk space before testing httphandler pkg
run: df -h
- name: Test httphandler pkg
run: ${{ env.DOCKER_CMD }} sh -c 'cd httphandler && go test -v ./...'
if: startsWith(github.ref, 'refs/tags')
- name: (debug) Step 5 - Check disk space before setting up Syft
run: df -h
- uses: anchore/sbom-action/download-syft@v0
name: Setup Syft
- name: (debug) Step 6 - Check disk space before goreleaser
run: df -h
- uses: goreleaser/goreleaser-action@v6
name: Build
with:
distribution: goreleaser
version: latest
args: release --clean --snapshot
env:
RELEASE: ${{ inputs.RELEASE }}
CLIENT: ${{ inputs.CLIENT }}
CGO_ENABLED: ${{ inputs.CGO_ENABLED }}
- name: (debug) Step 7 - Check disk space before smoke testing
run: df -h
- name: Smoke Testing
env:
RELEASE: ${{ inputs.RELEASE }}
KUBESCAPE_SKIP_UPDATE_CHECK: "true"
run: ${{ env.DOCKER_CMD }} python3 smoke_testing/init.py ${PWD}/dist/kubescape-ubuntu-latest
- name: (debug) Step 8 - Check disk space before golangci-lint
run: df -h
- name: golangci-lint
continue-on-error: true
uses: golangci/golangci-lint-action@v3
with:
version: latest
args: --timeout 10m
only-new-issues: true
skip-pkg-cache: true
skip-build-cache: true
- name: (debug) Step 9 - Check disk space before uploading artifacts
run: df -h
- uses: actions/upload-artifact@v4
name: Upload artifacts
with:
name: kubescape
path: dist/*
if-no-files-found: error
- name: (debug) Step 10 - Check disk space after uploading artifacts
run: df -h
build-http-image:
permissions:
contents: write
id-token: write
packages: write
pull-requests: read
needs: [check-secret]
uses: kubescape/workflows/.github/workflows/incluster-comp-pr-merged.yaml@main
with:
IMAGE_NAME: quay.io/${{ github.repository_owner }}/kubescape
IMAGE_TAG: ${{ inputs.RELEASE }}
COMPONENT_NAME: kubescape
CGO_ENABLED: 0
GO111MODULE: "on"
BUILD_PLATFORM: linux/amd64,linux/arm64
GO_VERSION: "1.23"
REQUIRED_TESTS: '[
"ks_microservice_create_2_cronjob_mitre_and_nsa_proxy",
"ks_microservice_triggering_with_cron_job",
"ks_microservice_update_cronjob_schedule",
"ks_microservice_delete_cronjob",
"ks_microservice_create_2_cronjob_mitre_and_nsa",
"ks_microservice_ns_creation",
"ks_microservice_on_demand",
"ks_microservice_mitre_framework_on_demand",
"ks_microservice_nsa_and_mitre_framework_demand",
"scan_nsa",
"scan_mitre",
"scan_with_exceptions",
"scan_repository",
"scan_local_file",
"scan_local_glob_files",
"scan_local_list_of_files",
"scan_with_exception_to_backend",
"scan_nsa_and_submit_to_backend",
"scan_mitre_and_submit_to_backend",
"scan_local_repository_and_submit_to_backend",
"scan_repository_from_url_and_submit_to_backend",
"scan_with_custom_framework",
"scan_customer_configuration",
"scan_compliance_score"
]'
COSIGN: true
HELM_E2E_TEST: true
FORCE: true
secrets: inherit
run-tests:
strategy:
fail-fast: false
matrix:
TEST: ${{ fromJson(needs.wf-preparation.outputs.TEST_NAMES) }}
needs: [wf-preparation, binary-build]
if: ${{ (needs.wf-preparation.outputs.is-secret-set == 'true') && (always() && (contains(needs.*.result, 'success') || contains(needs.*.result, 'skipped')) && !(contains(needs.*.result, 'failure')) && !(contains(needs.*.result, 'cancelled'))) }}
runs-on: ubuntu-latest # This cannot change
steps:
- uses: actions/download-artifact@v4
id: download-artifact
with:
name: kubescape
path: "~"
- run: ls -laR
- name: chmod +x
run: chmod +x -R ${{steps.download-artifact.outputs.download-path}}/kubescape-ubuntu-latest
- name: Checkout systests repo
uses: actions/checkout@v4
with:
repository: armosec/system-tests
path: .
- uses: actions/setup-python@v4
with:
python-version: '3.8.13'
cache: 'pip'
- name: create env
run: ./create_env.sh
- name: Generate uuid
id: uuid
run: |
echo "RANDOM_UUID=$(uuidgen)" >> $GITHUB_OUTPUT
- name: Create k8s Kind Cluster
id: kind-cluster-install
uses: helm/kind-action@v1.10.0
with:
cluster_name: ${{ steps.uuid.outputs.RANDOM_UUID }}
- name: run-tests-on-local-built-kubescape
env:
CUSTOMER: ${{ secrets.CUSTOMER }}
USERNAME: ${{ secrets.USERNAME }}
PASSWORD: ${{ secrets.PASSWORD }}
CLIENT_ID: ${{ secrets.CLIENT_ID_PROD }}
SECRET_KEY: ${{ secrets.SECRET_KEY_PROD }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
echo "Test history:"
echo " ${{ matrix.TEST }} " >/tmp/testhistory
cat /tmp/testhistory
source systests_python_env/bin/activate
python3 systest-cli.py \
-t ${{ matrix.TEST }} \
-b production \
-c CyberArmorTests \
--duration 3 \
--logger DEBUG \
--kwargs kubescape=${{steps.download-artifact.outputs.download-path}}/kubescape-ubuntu-latest
deactivate
- name: Test Report
uses: mikepenz/action-junit-report@v5
if: always() # always run even if the previous step fails
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
report_paths: '**/results_xml_format/**.xml'
commit: ${{github.event.workflow_run.head_sha}}

41
.github/workflows/build-image.yaml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: build-image
permissions: read-all
on:
workflow_dispatch:
inputs:
CLIENT:
required: false
type: string
default: "test"
IMAGE_TAG:
required: true
type: string
CO_SIGN:
type: boolean
required: false
default: false
PLATFORMS:
type: boolean
required: false
default: false
jobs:
build-http-image:
permissions:
id-token: write
packages: write
contents: write
pull-requests: read
uses: kubescape/workflows/.github/workflows/incluster-comp-pr-merged.yaml@main
with:
IMAGE_NAME: quay.io/${{ github.repository_owner }}/kubescape
IMAGE_TAG: ${{ inputs.IMAGE_TAG }}
COMPONENT_NAME: kubescape
CGO_ENABLED: 0
GO111MODULE: "on"
BUILD_PLATFORM: ${{ inputs.PLATFORMS && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
GO_VERSION: "1.23"
REQUIRED_TESTS: '[]'
COSIGN: ${{ inputs.CO_SIGN }}
HELM_E2E_TEST: false
FORCE: true
secrets: inherit

86
.github/workflows/c-create-release.yaml vendored Normal file
View File

@@ -0,0 +1,86 @@
name: c-create_release
permissions: read-all
on:
workflow_call:
inputs:
RELEASE_NAME:
description: 'Release name'
required: true
type: string
TAG:
description: 'Tag name'
required: true
type: string
DRAFT:
description: 'Create draft release'
required: false
type: boolean
default: false
jobs:
create-release:
name: create-release
runs-on: ubuntu-latest
env:
MAC_OS: macos-latest
UBUNTU_OS: ubuntu-latest
WINDOWS_OS: windows-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
id: download-artifact
with:
name: kubescape
path: .
# TODO: kubescape-windows-latest is deprecated and should be removed
- name: Get kubescape.exe from kubescape-windows-latest.exe
run: cp ${{steps.download-artifact.outputs.download-path}}/kubescape-${{ env.WINDOWS_OS }}.exe ${{steps.download-artifact.outputs.download-path}}/kubescape.exe
- name: Set release token
id: set-token
run: |
if [ "${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}" != "" ]; then
echo "token=${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}" >> $GITHUB_OUTPUT;
else
echo "token=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_OUTPUT;
fi
- name: List artifacts
run: |
find . -type f -print
- name: Release
uses: softprops/action-gh-release@v2
with:
token: ${{ steps.set-token.outputs.token }}
name: ${{ inputs.RELEASE_NAME }}
tag_name: ${{ inputs.TAG }}
body: ${{ github.event.pull_request.body }}
draft: ${{ inputs.DRAFT }}
prerelease: false
fail_on_unmatched_files: true
files: |
./checksums.sha256
./kubescape-${{ env.MAC_OS }}
./kubescape-${{ env.MAC_OS }}.sbom
./kubescape-${{ env.MAC_OS }}.tar.gz
./kubescape-${{ env.UBUNTU_OS }}
./kubescape-${{ env.UBUNTU_OS }}.sbom
./kubescape-${{ env.UBUNTU_OS }}.tar.gz
./kubescape-${{ env.WINDOWS_OS }}.exe
./kubescape-${{ env.WINDOWS_OS }}.exe.sbom
./kubescape-${{ env.WINDOWS_OS }}.tar.gz
./kubescape-arm64-${{ env.MAC_OS }}
./kubescape-arm64-${{ env.MAC_OS }}.sbom
./kubescape-arm64-${{ env.MAC_OS }}.tar.gz
./kubescape-arm64-${{ env.UBUNTU_OS }}
./kubescape-arm64-${{ env.UBUNTU_OS }}.sbom
./kubescape-arm64-${{ env.UBUNTU_OS }}.tar.gz
./kubescape-arm64-${{ env.WINDOWS_OS }}.exe
./kubescape-arm64-${{ env.WINDOWS_OS }}.exe.sbom
./kubescape-arm64-${{ env.WINDOWS_OS }}.tar.gz
./kubescape-riscv64-${{ env.UBUNTU_OS }}
./kubescape-riscv64-${{ env.UBUNTU_OS }}.sbom
./kubescape-riscv64-${{ env.UBUNTU_OS }}.tar.gz
./kubescape.exe

107
.github/workflows/d-publish-image.yaml vendored Normal file
View File

@@ -0,0 +1,107 @@
name: d-publish-image
permissions:
actions: read
checks: read
contents: write
deployments: read
discussions: read
id-token: write
issues: read
packages: read
pages: read
pull-requests: read
repository-projects: read
statuses: read
security-events: read
on:
workflow_call:
inputs:
client:
description: 'client name'
required: true
type: string
image_tag:
description: 'image tag'
required: true
type: string
image_name:
description: 'image registry and name'
required: true
type: string
cosign:
required: false
default: false
type: boolean
description: 'run cosign on released image'
support_platforms:
required: false
default: true
type: boolean
description: 'support amd64/arm64'
jobs:
check-secret:
name: check if QUAYIO_REGISTRY_USERNAME & QUAYIO_REGISTRY_PASSWORD is set in github secrets
runs-on: ubuntu-latest
outputs:
is-secret-set: ${{ steps.check-secret-set.outputs.is-secret-set }}
steps:
- name: check if QUAYIO_REGISTRY_USERNAME & QUAYIO_REGISTRY_PASSWORD is set in github secrets
id: check-secret-set
env:
QUAYIO_REGISTRY_USERNAME: ${{ secrets.QUAYIO_REGISTRY_USERNAME }}
QUAYIO_REGISTRY_PASSWORD: ${{ secrets.QUAYIO_REGISTRY_PASSWORD }}
run: |
echo "is-secret-set=${{ env.QUAYIO_REGISTRY_USERNAME != '' && env.QUAYIO_REGISTRY_PASSWORD != '' }}" >> $GITHUB_OUTPUT
build-cli-image:
needs: [check-secret]
if: needs.check-secret.outputs.is-secret-set == 'true'
name: Build image and upload to registry
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Quay.io
env:
QUAY_PASSWORD: ${{ secrets.QUAYIO_REGISTRY_PASSWORD }}
QUAY_USERNAME: ${{ secrets.QUAYIO_REGISTRY_USERNAME }}
run: docker login -u="${QUAY_USERNAME}" -p="${QUAY_PASSWORD}" quay.io
- uses: actions/download-artifact@v4
id: download-artifact
with:
name: kubescape
path: .
- name: mv kubescape amd64 binary
run: mv kubescape-ubuntu-latest kubescape-amd64-ubuntu-latest
- name: chmod +x
run: chmod +x -v kubescape-a*
- name: Build and push images
run: docker buildx build . --file build/kubescape-cli.Dockerfile --tag ${{ inputs.image_name }}:${{ inputs.image_tag }} --tag ${{ inputs.image_name }}:latest --build-arg image_version=${{ inputs.image_tag }} --build-arg client=${{ inputs.client }} --push --platform linux/amd64,linux/arm64
- name: Install cosign
uses: sigstore/cosign-installer@main
with:
cosign-release: 'v2.2.2'
- name: sign kubescape container image
if: ${{ inputs.cosign }}
env:
COSIGN_EXPERIMENTAL: "true"
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY_V1 }}
COSIGN_PRIVATE_KEY_PASSWORD: ${{ secrets.COSIGN_PRIVATE_KEY_V1_PASSWORD }}
COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY_V1 }}
run: |
# Sign the image with keyless mode
cosign sign -y ${{ inputs.image_name }}:${{ inputs.image_tag }}
# Sign the image with key for verifier clients without keyless support
# Put the key from environment variable to a file
echo "$COSIGN_PRIVATE_KEY" > cosign.key
printf "$COSIGN_PRIVATE_KEY_PASSWORD" | cosign sign -key cosign.key -y ${{ inputs.image_name }}:${{ inputs.image_tag }}
rm cosign.key
# Verify the image
echo "$COSIGN_PUBLIC_KEY" > cosign.pub
cosign verify -key cosign.pub ${{ inputs.image_name }}:${{ inputs.image_tag }}

46
.github/workflows/e-post-release.yaml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: e-post_release
permissions: read-all
on:
workflow_call:
inputs:
TAG:
description: 'Tag name'
required: true
type: string
jobs:
post_release:
name: Post release jobs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Update new version in krew-index
uses: rajatjindal/krew-release-bot@v0.0.47
if: github.repository_owner == 'kubescape'
env:
GITHUB_REF: ${{ inputs.TAG }}
- name: Invoke workflow to update packaging
uses: benc-uk/workflow-dispatch@v1
if: github.repository_owner == 'kubescape'
with:
workflow: release.yml
repo: kubescape/packaging
ref: refs/heads/main
token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}
- name: Invoke workflow to update homebrew tap
uses: benc-uk/workflow-dispatch@v1
if: github.repository_owner == 'kubescape'
with:
workflow: release.yml
repo: kubescape/homebrew-tap
ref: refs/heads/main
token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}
- name: Invoke workflow to update github action
uses: benc-uk/workflow-dispatch@v1
if: github.repository_owner == 'kubescape'
with:
workflow: release.yaml
repo: kubescape/github-action
ref: refs/heads/main
token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}

View File

@@ -37,7 +37,7 @@ jobs:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@v2.4.3
uses: ossf/scorecard-action@v2.4.0
with:
results_file: results.sarif
results_format: sarif

View File

@@ -0,0 +1,20 @@
permissions: read-all
on:
issues:
types: [opened, labeled]
jobs:
open_PR_message:
if: github.event.label.name == 'typo'
runs-on: ubuntu-latest
steps:
- uses: ben-z/actions-comment-on-issue@1.0.2
with:
message: "Hello! :wave:\n\nThis issue is being automatically closed, Please open a PR with a relevant fix."
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
auto_close_issues:
runs-on: ubuntu-latest
steps:
- uses: lee-dohm/close-matching-issues@v2
with:
query: 'label:typo'
token: ${{ secrets.GITHUB_TOKEN }}

5
.gitignore vendored
View File

@@ -9,10 +9,5 @@
ca.srl
*.out
ks
cosign.key
dist/
# Test output files
customFilename.pdf
customFilename.xml

View File

@@ -1,57 +1,51 @@
version: "2"
linters-settings:
govet:
shadow: true
dupl:
threshold: 200
goconst:
min-len: 3
min-occurrences: 2
gocognit:
min-complexity: 65
linters:
enable:
- bodyclose
- gosec
- staticcheck
- nolintlint
- gofmt
- unused
- govet
- bodyclose
- typecheck
- goimports
- ineffassign
- gosimple
disable:
- dupl
# temporarily disabled
- errcheck
- gochecknoglobals
- gochecknoinits
- gocognit
- dupl
- gocritic
- lll
- gocognit
- nakedret
- revive
- stylecheck
- unconvert
- unparam
settings:
dupl:
threshold: 200
gocognit:
min-complexity: 65
goconst:
min-len: 3
min-occurrences: 2
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
rules:
- linters:
- revive
text: var-naming
- linters:
- revive
text: type name will be used as (.+?) by other packages, and that stutters
- linters:
- staticcheck
text: ST1003
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gofmt
- goimports
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
#- forbidigo # <- see later
# should remain disabled
- lll
- gochecknoinits
- gochecknoglobals
issues:
exclude-rules:
- linters:
- revive
text: "var-naming"
- linters:
- revive
text: "type name will be used as (.+?) by other packages, and that stutters"
- linters:
- stylecheck
text: "ST1003"

View File

@@ -11,111 +11,40 @@ before:
hooks:
# You may remove this if you don't use go modules.
- go mod tidy
- go test -v ./...
- go -C httphandler test -v ./...
archives:
- id: cli
ids:
- cli
- id: binaries
formats:
- binary
name_template: >-
{{ .Binary }}
- id: default
formats:
- tar.gz
name_template: >-
{{ .Binary }}
builds:
- id: cli
binary: kubescape
env:
- CGO_ENABLED=0
goos:
- goos:
- linux
- darwin
- windows
- darwin
goarch:
- amd64
- arm64
- riscv64
ldflags:
- -X main.version={{.Version}}
- -X main.commit={{.Commit}}
- -X main.date={{.Date}}
- -X github.com/kubescape/backend/pkg/versioncheck.Client={{.Env.CLIENT}}
hooks:
post:
- cmd: >
{{ if eq .Arch "amd64" }}
/bin/sh -lc 'sh build/goreleaser-post-e2e.sh'
{{ end }}
- id: downloader
dir: downloader
binary: downloader
env:
- CGO_ENABLED=0
goos:
- linux
goarch:
- amd64
- arm64
- id: http
dir: httphandler
binary: ksserver
env:
- CGO_ENABLED=0
goos:
- linux
goarch:
- amd64
- arm64
nfpms:
- id: cli
package_name: kubescape
ids:
- cli
vendor: Kubescape
homepage: https://kubescape.io/
maintainer: matthiasb@kubescape.io
formats:
- apk
- deb
- rpm
bindir: /usr/bin
docker_signs:
- stdin: "{{ .Env.COSIGN_PWD }}"
dockers_v2:
- id: cli
images:
- "quay.io/kubescape/kubescape-cli"
tags:
- "{{ .Tag }}"
labels:
"org.opencontainers.image.description": "Kubescape CLI"
"org.opencontainers.image.created": "{{.Date}}"
"org.opencontainers.image.name": "{{.ProjectName}}"
"org.opencontainers.image.revision": "{{.FullCommit}}"
"org.opencontainers.image.version": "{{.Version}}"
"org.opencontainers.image.source": "{{.GitURL}}"
ids:
- cli
dockerfile: build/kubescape-cli.Dockerfile
- id: http
images:
- "quay.io/kubescape/kubescape"
tags:
- "{{ .Tag }}"
labels:
"org.opencontainers.image.description": "Kubescape microservice"
"org.opencontainers.image.created": "{{.Date}}"
"org.opencontainers.image.name": "{{.ProjectName}}"
"org.opencontainers.image.revision": "{{.FullCommit}}"
"org.opencontainers.image.version": "{{.Version}}"
"org.opencontainers.image.source": "{{.GitURL}}"
ids:
- downloader
- http
dockerfile: build/Dockerfile
- -s -w
- -X "github.com/kubescape/kubescape/v3/core/cautils.BuildNumber={{.Env.RELEASE}}"
- -X "github.com/kubescape/kubescape/v3/core/cautils.Client={{.Env.CLIENT}}"
binary: >-
{{ .ProjectName }}-
{{- if eq .Arch "amd64" }}
{{- else }}{{ .Arch }}-{{ end }}
{{- if eq .Os "darwin" }}macos
{{- else if eq .Os "linux" }}ubuntu
{{- else }}{{ .Os }}{{ end }}-latest
no_unique_dist_dir: true
changelog:
sort: asc
@@ -129,20 +58,5 @@ checksum:
sboms:
- artifacts: binary
krews:
- name: kubescape
ids:
- cli
skip_upload: true
homepage: https://kubescape.io/
description: It includes risk analysis, security compliance, and misconfiguration scanning with an easy-to-use CLI interface, flexible output formats, and automated scanning capabilities.
short_description: Scan resources and cluster configs against security frameworks.
release:
draft: false
footer: >-
---
Released by [GoReleaser](https://github.com/goreleaser/goreleaser).
documents:
- "{{ .Binary }}.sbom"

View File

@@ -3,58 +3,40 @@ kind: Plugin
metadata:
name: kubescape
spec:
homepage: https://github.com/kubescape/kubescape/
shortDescription: Scan resources and cluster configs against security frameworks.
version: {{ .TagName }}
description: |
It includes risk analysis, security compliance, and misconfiguration scanning
with an easy-to-use CLI interface, flexible output formats, and automated scanning capabilities.
platforms:
- selector:
matchLabels:
os: linux
arch: amd64
{{ addURIAndSha "https://github.com/kubescape/kubescape/releases/download/" .TagName (printf "kubescape_%s_linux_amd64.tar.gz" .TagName) .TagName }}
bin: kubescape
- selector:
matchLabels:
os: linux
arch: arm64
{{ addURIAndSha "https://github.com/kubescape/kubescape/releases/download/" .TagName (printf "kubescape_%s_linux_arm64.tar.gz" .TagName) .TagName }}
bin: kubescape
- selector:
matchLabels:
os: darwin
arch: amd64
{{ addURIAndSha "https://github.com/kubescape/kubescape/releases/download/" .TagName (printf "kubescape_%s_darwin_amd64.tar.gz" .TagName) .TagName }}
{{ addURIAndSha "https://github.com/kubescape/kubescape/releases/download/{{ .TagName }}/kubescape-macos-latest.tar.gz" .TagName }}
bin: kubescape
- selector:
matchLabels:
os: darwin
arch: arm64
{{ addURIAndSha "https://github.com/kubescape/kubescape/releases/download/" .TagName (printf "kubescape_%s_darwin_arm64.tar.gz" .TagName) .TagName }}
{{ addURIAndSha "https://github.com/kubescape/kubescape/releases/download/{{ .TagName }}/kubescape-arm64-macos-latest.tar.gz" .TagName }}
bin: kubescape
- selector:
matchLabels:
os: linux
arch: amd64
{{ addURIAndSha "https://github.com/kubescape/kubescape/releases/download/{{ .TagName }}/kubescape-ubuntu-latest.tar.gz" .TagName }}
bin: kubescape
- selector:
matchLabels:
os: linux
arch: arm64
{{ addURIAndSha "https://github.com/kubescape/kubescape/releases/download/{{ .TagName }}/kubescape-arm64-ubuntu-latest.tar.gz" .TagName }}
bin: kubescape
- selector:
matchLabels:
os: windows
arch: amd64
{{ addURIAndSha "https://github.com/kubescape/kubescape/releases/download/" .TagName (printf "kubescape_%s_windows_amd64.tar.gz" .TagName) .TagName }}
{{ addURIAndSha "https://github.com/kubescape/kubescape/releases/download/{{ .TagName }}/kubescape-windows-latest.tar.gz" .TagName }}
bin: kubescape.exe
- selector:
matchLabels:
os: windows
arch: arm64
{{ addURIAndSha "https://github.com/kubescape/kubescape/releases/download/" .TagName (printf "kubescape_%s_windows_arm64.tar.gz" .TagName) .TagName }}
bin: kubescape.exe
shortDescription: Scan resources and cluster configs against security frameworks.
description: |
Kubescape is the first tool for testing if Kubernetes is deployed securely
according to mitigations and best practices. It includes risk analysis,
security compliance, and misconfiguration scanning with an easy-to-use
CLI interface, flexible output formats, and automated scanning capabilities.
Features:
- Risk analysis: Identify vulnerabilities and security risks in your cluster
- Security compliance: Check your cluster against multiple security frameworks
- Misconfiguration scanning: Detect security misconfigurations in your workloads
- Flexible output: Results in JSON, SARIF, HTML, JUnit, and Prometheus formats
- CI/CD integration: Easily integrate into your CI/CD pipeline
homepage: https://kubescape.io/
caveats: |
Requires kubectl and basic knowledge of Kubernetes.
Run 'kubescape scan' to scan your Kubernetes cluster or manifests.

View File

@@ -1,273 +0,0 @@
# Krew Release Automation Guide
This document explains how kubescape automates publishing to the Kubernetes plugin package manager, krew.
## What is Krew?
Krew is a plugin manager for `kubectl`. It allows users to discover and install `kubectl` plugins easily. You can learn more about krew at [https://krew.sigs.k8s.io/](https://krew.sigs.k8s.io/).
## How kubescape publishes to krew
We use the [krew-release-bot](https://github.com/rajatjindal/krew-release-bot) to automatically create pull requests to the [kubernetes-sigs/krew-index](https://github.com/kubernetes-sigs/krew-index) repository whenever a new release of kubescape is published.
### Setup Overview
The automation consists of three components:
1. **`.krew.yaml`** - A template file that the bot uses to generate the krew plugin manifest
2. **`.github/workflows/02-release.yaml`** - GitHub Actions workflow that runs the krew-release-bot after a successful release
3. **`.goreleaser.yaml`** - GoReleaser configuration that defines the krew manifest (though upload is skipped)
### Why Use krew-release-bot Instead of GoReleaser's Built-in Krew Support?
You might have noticed that **GoReleaser has built-in krew support** in its `krews` section. However, almost all projects (including stern) use `skip_upload: true` and rely on **krew-release-bot** instead. Here's why:
#### Problems with GoReleaser's Built-in Krew Publishing
To use GoReleaser's direct krew publishing, you would need to:
```yaml
krews:
- name: kubescape
skip_upload: false # Instead of true
repository:
owner: kubernetes-sigs
name: krew-index
token: "{{ .Env.KREW_INDEX_TOKEN }}" # Required!
pull_request:
enabled: true # Requires GoReleaser Pro for cross-repo PRs
```
This approach has several critical issues:
1. **Permission Barrier**: Almost no one has write access to `kubernetes-sigs/krew-index`. You would need special permissions from the Krew maintainers, which is rarely granted.
2. **Security Risk**: You'd need to store a GitHub personal access token with write access to the krew-index in your repository secrets. This token could be compromised and used to make unauthorized changes to the krew-index.
3. **GoReleaser Pro Required**: To create pull requests to a different repository (cross-repository), you need GoReleaser Pro, which is a paid product.
4. **Manual Work**: Even if you had access, you'd need to manually configure and maintain the repository settings, tokens, and potentially deal with rate limits and authentication issues.
#### Why krew-release-bot is the Right Solution
The **krew-release-bot** was created by the Kubernetes community (in collaboration with the Krew team) specifically to solve these problems:
- **No Repository Access Required**: The bot acts as an intermediary with pre-configured access to krew-index. You don't need write permissions.
- **No Tokens Needed**: It uses GitHub's `GITHUB_TOKEN` (automatically available in GitHub Actions) via webhooks and events. No personal access tokens required.
- **Designed for Krew**: It's specifically built for the krew-index workflow and integrates with Krew's automation.
- **Automatic Merging**: The Krew team has configured their CI to automatically test and merge PRs from krew-release-bot (usually within 5-10 minutes).
- **Officially Recommended**: The Krew team explicitly recommends this approach in their documentation as the standard way to automate plugin updates.
- **Free and Open Source**: No paid subscriptions required.
#### The Real-World Evidence
Looking at recent pull requests to `kubernetes-sigs/krew-index`, **almost all automated plugin updates are created by krew-release-bot**. You'll see patterns like:
```
Author: krew-release-bot
Title: "release new version v0.6.11 of radar"
```
This demonstrates that the entire Kubernetes ecosystem has standardized on krew-release-bot, not GoReleaser's built-in publishing.
#### Summary
While GoReleaser's built-in krew support exists in the code, it's **practically unusable for the krew-index repository** due to permission and security constraints. The krew-release-bot is the de facto standard because:
- It works without special permissions
- It's more secure
- It integrates with Krew's automation
- It's free and recommended by the Krew team
This is why we use `skip_upload: true` in GoReleaser and let krew-release-bot handle the actual publishing.
### The Template File
The `.krew.yaml` file in the repository root is a Go template that contains placeholders for dynamic values:
```yaml
apiVersion: krew.googlecontainertools.github.com/v1alpha2
kind: Plugin
metadata:
name: kubescape
spec:
version: {{ .TagName }}
platforms:
- selector:
matchLabels:
os: linux
arch: amd64
{{ $version := trimPrefix "v" .TagName }}{{ addURIAndSha "https://github.com/kubescape/kubescape/releases/download/" .TagName (printf "kubescape_%s_linux_amd64.tar.gz" $version) .TagName }}
bin: kubescape
- selector:
matchLabels:
os: linux
arch: arm64
{{ $version := trimPrefix "v" .TagName }}{{ addURIAndSha "https://github.com/kubescape/kubescape/releases/download/" .TagName (printf "kubescape_%s_linux_arm64.tar.gz" $version) .TagName }}
bin: kubescape
- selector:
matchLabels:
os: darwin
arch: amd64
{{ $version := trimPrefix "v" .TagName }}{{ addURIAndSha "https://github.com/kubescape/kubescape/releases/download/" .TagName (printf "kubescape_%s_darwin_amd64.tar.gz" $version) .TagName }}
bin: kubescape
- selector:
matchLabels:
os: darwin
arch: arm64
{{ $version := trimPrefix "v" .TagName }}{{ addURIAndSha "https://github.com/kubescape/kubescape/releases/download/" .TagName (printf "kubescape_%s_darwin_arm64.tar.gz" $version) .TagName }}
bin: kubescape
- selector:
matchLabels:
os: windows
arch: amd64
{{ $version := trimPrefix "v" .TagName }}{{ addURIAndSha "https://github.com/kubescape/kubescape/releases/download/" .TagName (printf "kubescape_%s_windows_amd64.tar.gz" $version) .TagName }}
bin: kubescape.exe
- selector:
matchLabels:
os: windows
arch: arm64
{{ $version := trimPrefix "v" .TagName }}{{ addURIAndSha "https://github.com/kubescape/kubescape/releases/download/" .TagName (printf "kubescape_%s_windows_arm64.tar.gz" $version) .TagName }}
bin: kubescape.exe
shortDescription: Scan resources and cluster configs against security frameworks.
description: |
Kubescape is the first tool for testing if Kubernetes is deployed securely
according to mitigations and best practices. It includes risk analysis,
security compliance, and misconfiguration scanning with an easy-to-use
CLI interface, flexible output formats, and automated scanning capabilities.
Features:
- Risk analysis: Identify vulnerabilities and security risks in your cluster
- Security compliance: Check your cluster against multiple security frameworks
- Misconfiguration scanning: Detect security misconfigurations in your workloads
- Flexible output: Results in JSON, SARIF, HTML, JUnit, and Prometheus formats
- CI/CD integration: Easily integrate into your CI/CD pipeline
homepage: https://kubescape.io/
caveats: |
Requires kubectl and basic knowledge of Kubernetes.
Run 'kubescape scan' to scan your Kubernetes cluster or manifests.
```
The `{{ .TagName }}` is replaced with the release tag (e.g., `v3.0.0`), `{{ trimPrefix "v" .TagName }}` removes the version prefix, and `{{ addURIAndSha ... }}` calculates the SHA256 checksum for the binary archive.
### Release Workflow
The release workflow (`.github/workflows/02-release.yaml`) can be triggered in two ways:
1. **Automatic**: When a new tag matching the pattern `v[0-9]+.[0-9]+.[0-9]+` is pushed to the repository
2. **Manual**: Via `workflow_dispatch` with an optional `skip_publish` input
When the workflow is triggered:
1. GoReleaser builds and publishes the release artifacts (unless `skip_publish=true` is set)
2. The krew-release-bot step runs conditionally:
- It **runs** when triggered by a tag push OR by `workflow_dispatch` with `skip_publish=false`
- It **skips** when triggered by `workflow_dispatch` with `skip_publish=true` (default)
3. When it runs, the bot:
- Reads the `.krew.yaml` template
- Fills in the template with release information
- Creates a pull request to the `kubernetes-sigs/krew-index` repository
- The PR is automatically tested and merged by krew's infrastructure
### Workflow Permissions
The release job has the following permissions:
```yaml
permissions:
actions: read
checks: read
contents: write
deployments: read
discussions: read
id-token: write
issues: read
models: read
packages: write
pages: read
pull-requests: read
repository-projects: read
statuses: read
security-events: read
attestations: read
artifact-metadata: read
```
These permissions are necessary for GoReleaser to create releases and upload artifacts.
### Testing the Template
Before committing changes to `.krew.yaml`, you can test how the template will be rendered using Docker:
```bash
docker run -v $(pwd)/.krew.yaml:/tmp/.krew.yaml ghcr.io/rajatjindal/krew-release-bot:v0.0.47 \
krew-release-bot template --tag v3.0.0 --template-file /tmp/.krew.yaml
```
This will output the generated krew manifest file, allowing you to verify:
- The version field is correct
- All download URLs are properly formatted
- The SHA256 checksum will be calculated correctly
### Why skip_upload in GoReleaser?
In `.goreleaser.yaml`, the `krews` section has `skip_upload: true`:
```yaml
krews:
- name: kubescape
ids:
- cli
skip_upload: true # We use krew-release-bot instead
homepage: https://kubescape.io/
description: It includes risk analysis, security compliance, and misconfiguration scanning with an easy-to-use CLI interface, flexible output formats, and automated scanning capabilities.
short_description: Scan resources and cluster configs against security frameworks.
```
This is intentional because:
- GoReleaser generates the manifest but doesn't have built-in support for submitting PRs to krew-index
- krew-release-bot is the recommended tool for krew automation by the Krew team
- Using krew-release-bot provides automatic testing and merging of version bump PRs
### Manual Release Testing
You can test the release workflow manually without publishing to krew by using `workflow_dispatch`:
1. Go to Actions tab in GitHub
2. Select "02-create_release" workflow
3. Click "Run workflow"
4. The `skip_publish` input defaults to `true` (publishing will be skipped)
5. Set `skip_publish` to `false` if you want to test the full release process including krew indexing
### Making Changes to the Template
If you need to update the krew manifest (e.g., change the description, add platforms, or update the binary location):
1. Edit the `.krew.yaml` file
2. Test your changes with the Docker command shown above
3. Commit and push the changes
4. The next release will use the updated template
### Installing kubescape via krew
Once the plugin is indexed in krew, users can install it with:
```bash
kubectl krew install kubernetes-sigs/kubescape
```
Or after index update:
```bash
kubectl krew install kubescape
```
### Further Reading
- [Krew official documentation](https://krew.sigs.k8s.io/docs/developer-guide/)
- [krew-release-bot repository](https://github.com/rajatjindal/krew-release-bot)
- [Krew plugin submission guide](https://krew.sigs.k8s.io/docs/developer-guide/develop/plugins/)

471
README.md
View File

@@ -8,7 +8,6 @@
[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fkubescape%2Fkubescape.svg?type=shield&issueType=license)](https://app.fossa.com/projects/git%2Bgithub.com%2Fkubescape%2Fkubescape?ref=badge_shield&issueType=license)
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/6944/badge)](https://www.bestpractices.dev/projects/6944)
[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/kubescape/kubescape/badge)](https://securityscorecards.dev/viewer/?uri=github.com/kubescape/kubescape)
[![Docs](https://img.shields.io/badge/docs-latest-brightgreen?logo=gitbook)](https://kubescape.io/docs/)
[![Stars](https://img.shields.io/github/stars/kubescape/kubescape?style=social)](https://github.com/kubescape/kubescape/stargazers)
[![Twitter Follow](https://img.shields.io/twitter/follow/kubescape?style=social)](https://twitter.com/kubescape)
[![Slack](https://img.shields.io/badge/slack-kubescape-blueviolet?logo=slack)](https://cloud-native.slack.com/archives/C04EY3ZF9GE)
@@ -23,478 +22,100 @@
_Comprehensive Kubernetes Security from Development to Runtime_
Kubescape is an open-source Kubernetes security platform that provides comprehensive security coverage, from left to right across the entire development and deployment lifecycle. It offers hardening, posture management, and runtime security capabilities to ensure robust protection for Kubernetes environments.
Kubescape is an open-source Kubernetes security platform that provides comprehensive security coverage, from left to right across the entire development and deployment lifecycle. It offers hardening, posture management, and runtime security capabilities to ensure robust protection for Kubernetes environments. It saves Kubernetes users and admins precious time, effort, and resources.
Kubescape scans clusters, YAML files, and Helm charts. It detects misconfigurations according to multiple frameworks (including [NSA-CISA](https://www.armosec.io/blog/kubernetes-hardening-guidance-summary-by-armo/?utm_source=github&utm_medium=repository), [MITRE ATT&CK®](https://www.armosec.io/glossary/mitre-attck-framework/?utm_source=github&utm_medium=repository) and the [CIS Benchmark](https://www.armosec.io/blog/cis-kubernetes-benchmark-framework-scanning-tools-comparison/?utm_source=github&utm_medium=repository)).
Kubescape was created by [ARMO](https://www.armosec.io/?utm_source=github&utm_medium=repository) and is a [Cloud Native Computing Foundation (CNCF) incubating project](https://www.cncf.io/projects/).
_Please [star ⭐](https://github.com/kubescape/kubescape/stargazers) the repo if you want us to continue developing and improving Kubescape!_
_Please [star ⭐](https://github.com/kubescape/kubescape/stargazers) the repo if you want us to continue developing and improving Kubescape! 😀_
---
## Demo
## 📑 Table of Contents
Kubescape has a command line tool that you can use to quickly get a report on the security posture of a Kubernetes cluster:
- [Features](#-features)
- [Demo](#-demo)
- [Quick Start](#-quick-start)
- [Installation](#-installation)
- [CLI Commands](#%EF%B8%8F-cli-commands)
- [Usage Examples](#-usage-examples)
- [Architecture](#%EF%B8%8F-architecture)
- [In-Cluster Operator](#%EF%B8%8F-in-cluster-operator)
- [Integrations](#-integrations)
- [Community](#-community)
- [Changelog](#changelog)
- [License](#license)
<img src="docs/img/demo-v3.gif">
---
## Getting started
## ✨ Features
| Feature | Description |
|---------|-------------|
| 🔍 **Misconfiguration Scanning** | Scan clusters, YAML files, and Helm charts against NSA-CISA, MITRE ATT&CK®, and CIS Benchmarks |
| 🐳 **Image Vulnerability Scanning** | Detect CVEs in container images using [Grype](https://github.com/anchore/grype) |
| 🩹 **Image Patching** | Automatically patch vulnerable images using [Copacetic](https://github.com/project-copacetic/copacetic) |
| 🔧 **Auto-Remediation** | Automatically fix misconfigurations in Kubernetes manifests |
| 🛡️ **Admission Control** | Enforce security policies with Validating Admission Policies (VAP) |
| 📊 **Runtime Security** | eBPF-based runtime monitoring via [Inspektor Gadget](https://github.com/inspektor-gadget) |
| 🤖 **AI Integration** | MCP server for AI assistant integration |
---
## 🎬 Demo
<img src="docs/img/demo-v3.gif" alt="Kubescape CLI demo">
---
## 🚀 Quick Start
### 1. Install Kubescape
Experimenting with Kubescape is as easy as:
```sh
curl -s https://raw.githubusercontent.com/kubescape/kubescape/master/install.sh | /bin/bash
```
> 💡 See [Installation](#-installation) for more options (Homebrew, Krew, Windows, etc.)
This script will automatically download the latest Kubescape CLI release and scan the Kubernetes cluster in your current kubectl context.
### 2. Run Your First Scan
Learn more about:
```sh
# Scan your current cluster
kubescape scan
* [Installing the Kubescape CLI](https://kubescape.io/docs/install-cli/)
* [Running your first scan](https://kubescape.io/docs/scanning/)
* [Accepting risk with exceptions](https://kubescape.io/docs/accepting-risk/)
# Scan a specific YAML file or directory
kubescape scan /path/to/manifests/
# Scan a container image for vulnerabilities
kubescape scan image nginx:latest
```
### 3. Explore the Results
Kubescape provides a detailed security posture overview including:
- Control plane security status
- Access control risks
- Workload misconfigurations
- Network policy gaps
- Compliance scores (MITRE, NSA)
---
## 📦 Installation
### One-Line Install (Linux/macOS)
```bash
curl -s https://raw.githubusercontent.com/kubescape/kubescape/master/install.sh | /bin/bash
```
### Package Managers
| Platform | Command |
|----------|---------|
| **Homebrew** | `brew install kubescape` |
| **Krew** | `kubectl krew install kubescape` |
| **Arch Linux** | `yay -S kubescape` |
| **Ubuntu** | `sudo add-apt-repository ppa:kubescape/kubescape && sudo apt install kubescape` |
| **NixOS** | `nix-shell -p kubescape` |
| **Chocolatey** | `choco install kubescape` |
| **Scoop** | `scoop install kubescape` |
### Windows (PowerShell)
```powershell
iwr -useb https://raw.githubusercontent.com/kubescape/kubescape/master/install.ps1 | iex
```
📖 **[Full Installation Guide →](docs/installation.md)**
---
## 🛠️ CLI Commands
Kubescape provides a comprehensive CLI with the following commands:
| Command | Description |
|---------|-------------|
| [`kubescape scan`](#scanning) | Scan cluster, files, or images for security issues |
| [`kubescape scan image`](#image-scanning) | Scan container images for vulnerabilities |
| [`kubescape fix`](#auto-fix) | Auto-fix misconfigurations in manifest files |
| [`kubescape patch`](#image-patching) | Patch container images to fix vulnerabilities |
| [`kubescape list`](#list-frameworks-and-controls) | List available frameworks and controls |
| [`kubescape download`](#offline-support) | Download artifacts for offline/air-gapped use |
| [`kubescape config`](#configuration) | Manage cached configurations |
| [`kubescape operator`](#operator-commands) | Interact with in-cluster Kubescape operator |
| [`kubescape vap`](#validating-admission-policies) | Manage Validating Admission Policies |
| [`kubescape mcpserver`](#mcp-server) | Start MCP server for AI assistant integration |
| `kubescape completion` | Generate shell completion scripts |
| `kubescape version` | Display version information |
---
## 📖 Usage Examples
### Scanning
#### Scan a Running Cluster
```bash
# Default scan (all frameworks)
kubescape scan
# Scan with a specific framework
kubescape scan framework nsa
kubescape scan framework mitre
kubescape scan framework cis-v1.23-t1.0.1
# Scan a specific control
kubescape scan control C-0005 -v
```
#### Scan Files and Repositories
```bash
# Scan local YAML files
kubescape scan /path/to/manifests/
# Scan a Helm chart
kubescape scan /path/to/helm/chart/
# Scan a Git repository
kubescape scan https://github.com/kubescape/kubescape
# Scan with Kustomize
kubescape scan /path/to/kustomize/directory/
```
#### Scan Options
```bash
# Include/exclude namespaces
kubescape scan --include-namespaces production,staging
kubescape scan --exclude-namespaces kube-system,kube-public
# Use alternative kubeconfig
kubescape scan --kubeconfig /path/to/kubeconfig
# Set compliance threshold (exit code 1 if below threshold)
kubescape scan --compliance-threshold 80
# Set severity threshold
kubescape scan --severity-threshold high
```
#### Output Formats
```bash
# JSON output
kubescape scan --format json --output results.json
# JUnit XML (for CI/CD)
kubescape scan --format junit --output results.xml
# SARIF (for GitHub Code Scanning)
kubescape scan --format sarif --output results.sarif
# HTML report
kubescape scan --format html --output report.html
# PDF report
kubescape scan --format pdf --output report.pdf
```
### Image Scanning
```bash
# Scan a public image
kubescape scan image nginx:1.21
# Scan with verbose output
kubescape scan image nginx:1.21 -v
# Scan a private registry image
kubescape scan image myregistry/myimage:tag --username user --password pass
```
### Auto-Fix
Automatically fix misconfigurations in your manifest files:
```bash
# First, scan and save results to JSON
kubescape scan /path/to/manifests --format json --output results.json
# Then apply fixes
kubescape fix results.json
# Dry run (preview changes without applying)
kubescape fix results.json --dry-run
# Apply fixes without confirmation prompts
kubescape fix results.json --no-confirm
```
### Image Patching
Patch container images to fix OS-level vulnerabilities:
```bash
# Start buildkitd (required)
sudo buildkitd &
# Patch an image
sudo kubescape patch --image docker.io/library/nginx:1.22
# Specify custom output tag
sudo kubescape patch --image nginx:1.22 --tag nginx:1.22-patched
# See detailed vulnerability report
sudo kubescape patch --image nginx:1.22 -v
```
📖 **[Full Patch Command Documentation →](cmd/patch/README.md)**
### List Frameworks and Controls
```bash
# List available frameworks
kubescape list frameworks
# List all controls
kubescape list controls
# Output as JSON
kubescape list controls --format json
```
### Offline Support
Download artifacts for air-gapped environments:
```bash
# Download all artifacts
kubescape download artifacts --output /path/to/offline/dir
# Download a specific framework
kubescape download framework nsa --output /path/to/nsa.json
# Scan using downloaded artifacts
kubescape scan --use-artifacts-from /path/to/offline/dir
```
### Configuration
```bash
# View current configuration
kubescape config view
# Set account ID
kubescape config set accountID <your-account-id>
# Delete cached configuration
kubescape config delete
```
### Operator Commands
Interact with the in-cluster Kubescape operator:
```bash
# Trigger a configuration scan
kubescape operator scan configurations
# Trigger a vulnerability scan
kubescape operator scan vulnerabilities
```
### Validating Admission Policies
Manage Kubernetes Validating Admission Policies:
```bash
# Deploy the Kubescape CEL admission policy library
kubescape vap deploy-library | kubectl apply -f -
# Create a policy binding
kubescape vap create-policy-binding \
--name my-policy-binding \
--policy c-0016 \
--namespace my-namespace | kubectl apply -f -
```
### MCP Server
Start an MCP (Model Context Protocol) server for AI assistant integration:
```bash
kubescape mcpserver
```
The MCP server exposes Kubescape's vulnerability and configuration scan data to AI assistants, enabling natural language queries about your cluster's security posture.
**Available MCP Tools:**
- `list_vulnerability_manifests` - Discover vulnerability manifests
- `list_vulnerabilities_in_manifest` - List CVEs in a manifest
- `list_vulnerability_matches_for_cve` - Get details for a specific CVE
- `list_configuration_security_scan_manifests` - List configuration scan results
- `get_configuration_security_scan_manifest` - Get configuration scan details
---
## 🏗️ Architecture
Kubescape can run in two modes:
### CLI Mode
The CLI is a standalone tool that scans clusters, files, and images on-demand.
_Did you know you can use Kubescape in all these places?_
<div align="center">
<img src="docs/img/ks-cli-arch.png" width="600" alt="CLI Architecture">
<img src="docs/img/ksfromcodetodeploy.png" alt="Places you can use Kubescape: in your IDE, CI, CD, or against a running cluster.">
</div>
**Key Components:**
- **[Open Policy Agent (OPA)](https://github.com/open-policy-agent/opa)** - Policy evaluation engine
- **[Regolibrary](https://github.com/kubescape/regolibrary)** - Library of security controls
- **[Grype](https://github.com/anchore/grype)** - Image vulnerability scanning
- **[Copacetic](https://github.com/project-copacetic/copacetic)** - Image patching
### Continuous security monitoring with the Kubescape Operator
### Operator Mode (In-Cluster)
As well as a CLI, Kubescape provides an in-cluster mode, which is installed via a Helm chart. Kubescape in-cluster provides extensive features such as continuous scanning, image vulnerability scanning, runtime analysis, network policy generation, and more. [Learn more about the Kubescape operator](https://kubescape.io/docs/operator/).
For continuous monitoring, deploy the Kubescape operator via Helm.
### Using Kubescape as a GitHub Action
<div align="center">
<img src="docs/img/ks-operator-arch.png" width="600" alt="Operator Architecture">
</div>
Kubescape can be used as a GitHub Action. This is a great way to integrate Kubescape into your CI/CD pipeline. You can find the Kubescape GitHub Action in the [GitHub Action marketplace](https://github.com/marketplace/actions/kubescape).
**Additional Capabilities:**
- Continuous configuration scanning
- Image vulnerability scanning
- Runtime analysis with eBPF
- Network policy generation
## Under the hood
📖 **[Full Architecture Documentation →](docs/architecture.md)**
Kubescape uses [Open Policy Agent](https://github.com/open-policy-agent/opa) to verify Kubernetes objects against [a library of posture controls](https://github.com/kubescape/regolibrary).
For image scanning, it uses [Grype](https://github.com/anchore/grype).
For image patching, it uses [Copacetic](https://github.com/project-copacetic/copacetic).
For eBPF, it uses [Inspektor Gadget](https://github.com/inspektor-gadget)
---
By default, CLI scan results are printed in a console-friendly manner, but they can be:
## ☸️ In-Cluster Operator
* exported to JSON, junit XML or SARIF
* rendered to HTML or PDF
* submitted to a [cloud service](docs/providers.md)
The Kubescape operator provides continuous security monitoring in your cluster:
### In-cluster architecture
```bash
# Add the Kubescape Helm repository
helm repo add kubescape https://kubescape.github.io/helm-charts/
![Architecture diagram](docs/img/architecture-diagram.png)
# Install the operator
helm upgrade --install kubescape kubescape/kubescape-operator \
--namespace kubescape \
--create-namespace
```
## Community
**Operator Features:**
- 🔄 Continuous misconfiguration scanning
- 🐳 Image vulnerability scanning for all workloads
- 🔍 Runtime threat detection (eBPF-based)
- 🌐 Network policy generation
- 📈 Prometheus metrics integration
Kubescape is an open source project. We welcome your feedback and ideas for improvement. We are part of the CNCF community and are evolving Kubescape in sync with the security needs of Kubernetes users. To learn more about where Kubescape is heading, please check out our [ROADMAP](https://github.com/kubescape/project-governance/blob/main/ROADMAP.md).
📖 **[Operator Installation Guide →](https://kubescape.io/docs/operator/)**
If you feel inspired to contribute to Kubescape, check out our [CONTRIBUTING](https://github.com/kubescape/project-governance/blob/main/CONTRIBUTING.md) file to learn how. You can find the issues we are working on (triage to development) on the [Kubescaping board](https://github.com/orgs/kubescape/projects/4/views/1)
---
* Feel free to pick a task from the [board](https://github.com/orgs/kubescape/projects/4) or suggest a feature of your own.
* Open an issue on the board. We aim to respond to all issues within 48 hours.
* [Join the CNCF Slack](https://slack.cncf.io/) and then our [users](https://cloud-native.slack.com/archives/C04EY3ZF9GE) or [developers](https://cloud-native.slack.com/archives/C04GY6H082K) channel.
## 🔌 Integrations
The Kubescape project follows the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/master/code-of-conduct.md).
### CI/CD
For more information about the Kubescape community, please visit [COMMUNITY](https://github.com/kubescape/project-governance/blob/main/COMMUNITY.md).
| Platform | Integration |
|----------|-------------|
| **GitHub Actions** | [kubescape/github-action](https://github.com/marketplace/actions/kubescape) |
| **GitLab CI** | [Documentation](https://kubescape.io/docs/integrations/gitlab/) |
| **Jenkins** | [Documentation](https://kubescape.io/docs/integrations/jenkins/) |
### IDE Extensions
We would like to take this opportunity to thank all our contibutors to date.
| IDE | Extension |
|-----|-----------|
| **VS Code** | [Kubescape Extension](https://marketplace.visualstudio.com/items?itemName=kubescape.kubescape) |
| **Lens** | [Kubescape Lens Extension](https://github.com/armosec/lens-kubescape) |
<br>
### Where You Can Use Kubescape
<div align="center">
<img src="docs/img/ksfromcodetodeploy.png" alt="Kubescape integration points: IDE, CI, CD, Runtime">
</div>
---
## 👥 Community
Kubescape is a CNCF incubating project with an active community.
### Get Involved
- 💬 **[Slack - Users Channel](https://cloud-native.slack.com/archives/C04EY3ZF9GE)** - Ask questions, get help
- 💬 **[Slack - Developers Channel](https://cloud-native.slack.com/archives/C04GY6H082K)** - Contribute to development
- 🐛 **[GitHub Issues](https://github.com/kubescape/kubescape/issues)** - Report bugs and request features
- 📋 **[Project Board](https://github.com/orgs/kubescape/projects/4)** - See what we're working on
- 🗺️ **[Roadmap](https://github.com/kubescape/project-governance/blob/main/ROADMAP.md)** - Future plans
### Contributing
We welcome contributions! Please see our:
- **[Contributing Guide](https://github.com/kubescape/project-governance/blob/main/CONTRIBUTING.md)**
- **[Code of Conduct](https://github.com/cncf/foundation/blob/master/code-of-conduct.md)**
### Community Resources
- **[Community Info](https://github.com/kubescape/project-governance/blob/main/COMMUNITY.md)**
- **[Governance](https://github.com/kubescape/project-governance/blob/main/GOVERNANCE.md)**
- **[Security Policy](https://github.com/kubescape/project-governance/blob/main/SECURITY.md)**
- **[Maintainers](https://github.com/kubescape/project-governance/blob/main/MAINTAINERS.md)**
### Contributors
<a href="https://github.com/kubescape/kubescape/graphs/contributors">
<img src="https://contrib.rocks/image?repo=kubescape/kubescape"/>
<a href = "https://github.com/kubescape/kubescape/graphs/contributors">
<img src = "https://contrib.rocks/image?repo=kubescape/kubescape"/>
</a>
---
## Changelog
Kubescape changes are tracked on the [releases page](https://github.com/kubescape/kubescape/releases).
---
Kubescape changes are tracked on the [release](https://github.com/kubescape/kubescape/releases) page.
## License
Copyright 2021-2025, the Kubescape Authors. All rights reserved.
Kubescape is released under the [Apache 2.0 license](LICENSE).
Copyright 2021-2025, the Kubescape Authors. All rights reserved. Kubescape is released under the Apache 2.0 license. See the [LICENSE](LICENSE) file for details.
Kubescape is a [Cloud Native Computing Foundation (CNCF) incubating project](https://www.cncf.io/projects/kubescape/) and was contributed by [ARMO](https://www.armosec.io/?utm_source=github&utm_medium=repository).
<div align="center">
<img src="https://raw.githubusercontent.com/cncf/artwork/refs/heads/main/other/cncf-member/incubating/color/cncf-incubating-color.svg" width="300" alt="CNCF Incubating Project">
</div>
</div>

View File

@@ -1,12 +1,25 @@
FROM gcr.io/distroless/static-debian13:nonroot
FROM --platform=$BUILDPLATFORM golang:1.23-bookworm AS builder
ENV GO111MODULE=on CGO_ENABLED=0
WORKDIR /work
ARG TARGETOS TARGETARCH
RUN --mount=target=. \
--mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \
cd httphandler && GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/ksserver .
RUN --mount=target=. \
--mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \
go run downloader/main.go
FROM gcr.io/distroless/static-debian12:nonroot
USER nonroot
WORKDIR /home/nonroot/
ARG TARGETPLATFORM
COPY $TARGETPLATFORM/downloader /usr/bin/downloader
RUN ["downloader"]
COPY $TARGETPLATFORM/ksserver /usr/bin/ksserver
COPY --from=builder /out/ksserver /usr/bin/ksserver
COPY --from=builder /root/.kubescape /home/nonroot/.kubescape
ARG image_version client
ENV RELEASE=$image_version CLIENT=$client

View File

@@ -1,241 +1,19 @@
# Building Kubescape
## Docker Build
This guide covers how to build Kubescape from source.
### Build your own Docker image
## Table of Contents
- [Prerequisites](#prerequisites)
- [Building the CLI](#building-the-cli)
- [Building Docker Images](#building-docker-images)
- [Build Options](#build-options)
- [Development Setup](#development-setup)
- [Troubleshooting](#troubleshooting)
---
## Prerequisites
### Required
- **Go 1.23+** - [Installation Guide](https://golang.org/doc/install)
- **Git** - For cloning the repository
- **Make** - For running build commands
### Optional (for Docker builds)
- **Docker** - [Installation Guide](https://docs.docker.com/get-docker/)
- **Docker Buildx** - For multi-platform builds (included with Docker Desktop)
- **GoReleaser** - [Installation Guide](https://goreleaser.com/install/)
### Verify Prerequisites
```bash
go version # Should be 1.23 or higher
git --version
make --version
docker --version # Optional
goreleaser --version # Optional
1. Clone Project
```
git clone https://github.com/kubescape/kubescape.git kubescape && cd "$_"
```
---
## Building the CLI
### Clone the Repository
```bash
git clone https://github.com/kubescape/kubescape.git
cd kubescape
2. Build kubescape CLI Docker image
```
make all
docker buildx build -t kubescape-cli -f build/kubescape-cli.Dockerfile --build-arg="ks_binary=kubescape" --load .
```
### Build with Make
```bash
# Build for your current platform
make build
# The binary will be at ./kubescape
./kubescape version
3. Build kubescape Docker image
```
### Build Directly with Go
```bash
go build -o kubescape .
docker buildx build -t kubescape -f build/Dockerfile --load .
```
### Build with GoReleaser
```bash
# Build for your current platform
RELEASE=v0.0.1 CLIENT=local goreleaser build --snapshot --clean --single-target
```
### Cross-Compilation
Build for different platforms:
```bash
# Linux (amd64)
GOOS=linux GOARCH=amd64 go build -o kubescape-linux-amd64 .
# Linux (arm64)
GOOS=linux GOARCH=arm64 go build -o kubescape-linux-arm64 .
# macOS (amd64)
GOOS=darwin GOARCH=amd64 go build -o kubescape-darwin-amd64 .
# macOS (arm64 / Apple Silicon)
GOOS=darwin GOARCH=arm64 go build -o kubescape-darwin-arm64 .
# Windows (amd64)
GOOS=windows GOARCH=amd64 go build -o kubescape-windows-amd64.exe .
```
---
## Building Docker Images
Kubescape uses [GoReleaser](https://goreleaser.com/) to build its Docker images. The Dockerfiles are specifically designed to work with GoReleaser's build pipeline, which handles cross-compilation and places binaries in the expected directory structure.
### Build with GoReleaser
The recommended way to build Docker images locally is using GoReleaser. Note that `RELEASE`, `CLIENT`, and `RUN_E2E` environment variables are required:
```bash
# Build all artifacts and Docker images locally without publishing
# --skip=before,krew,nfpm,sbom skips unnecessary steps for faster local builds
RELEASE=v0.0.1 CLIENT=local RUN_E2E=false goreleaser release --snapshot --clean --skip=before,nfpm,sbom
```
Please read the [GoReleaser documentation](https://goreleaser.com/customization/dockers_v2/#testing-locally) for more details on using it for local testing.
---
## Build Options
### Make Targets
| Target | Description |
|--------|-------------|
| `make build` | Build the Kubescape binary |
| `make test` | Run unit tests |
| `make all` | Build everything |
| `make clean` | Remove build artifacts |
### Build Tags
You can use Go build tags to customize the build:
```bash
# Example with build tags
go build -tags "netgo" -o kubescape .
```
### Version Information
To embed version information in the build:
```bash
VERSION=$(git describe --tags --always)
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
COMMIT=$(git rev-parse HEAD)
go build -ldflags "-X main.version=$VERSION -X main.buildDate=$BUILD_DATE -X main.commit=$COMMIT" -o kubescape .
```
---
## Development Setup
### Install Development Dependencies
```bash
# Install golangci-lint for linting
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
# Install other tools as needed
go mod download
```
### Run Tests
```bash
# Run all tests
make test
# Run tests with coverage
go test -cover ./...
# Run specific package tests
go test ./core/...
```
### Run Linter
```bash
golangci-lint run
```
### Code Formatting
```bash
go fmt ./...
```
---
## Troubleshooting
### Build Fails with "module not found"
```bash
# Update dependencies
go mod tidy
go mod download
```
### CGO-related Errors
If you encounter CGO errors, try building with CGO disabled:
```bash
CGO_ENABLED=0 go build -o kubescape .
```
### Docker Build Fails
Ensure Docker daemon is running and you have sufficient permissions.
If you encounter an error like `failed to calculate checksum ... "/linux/amd64/kubescape": not found`, it usually means you are trying to run `docker build` manually. Because the Dockerfiles are optimized for GoReleaser, you should use the `goreleaser release --snapshot` command described in the [Building Docker Images](#building-docker-images) section instead.
```bash
# Check Docker status
docker info
```
### Out of Memory During Build
For systems with limited memory:
```bash
# Limit Go's memory usage
GOGC=50 go build -o kubescape .
```
---
## Dockerfiles
| File | Description |
|------|-------------|
| `build/Dockerfile` | Full Kubescape image with HTTP handler |
| `build/kubescape-cli.Dockerfile` | Minimal CLI-only image |
---
## Related Documentation
- [Contributing Guide](https://github.com/kubescape/project-governance/blob/main/CONTRIBUTING.md)
- [Architecture](../docs/architecture.md)
- [Getting Started](../docs/getting-started.md)

View File

@@ -1,151 +0,0 @@
#!/usr/bin/env sh
#
# goreleaser-post-e2e.sh
#
# A small, robust POSIX shell script intended to be called from the goreleaser
# `builds[].hooks.post` entry. It is responsible for optionally running the
# repository smoke tests against the artifact produced in `dist/`.
#
# Usage:
# RUN_E2E=true -> enable running smoke tests
# E2E_FAIL_ON_ERROR=1 -> (default) treat test failures as fatal (exit non-zero)
# E2E_FAIL_ON_ERROR=0 -> treat test failures as non-fatal (log, but exit 0)
#
# The script is written to be defensive and to work under /bin/sh on CI runners.
# Use POSIX-safe flags only.
set -eu
# Helper for logging
_now() {
date --iso-8601=seconds 2>/dev/null || date
}
log() {
printf '%s [goreleaser-post-e2e] %s\n' "$(_now)" "$*"
}
# GitHub Actions log grouping helpers (no-op outside Actions)
gha_group_start() {
if [ "${GITHUB_ACTIONS:-}" = "true" ]; then
# Titles must be on a single line
printf '::group::%s\n' "$*"
fi
}
gha_group_end() {
if [ "${GITHUB_ACTIONS:-}" = "true" ]; then
printf '::endgroup::\n'
fi
}
# Small helper to interpret various truthy forms (1/true/yes/y)
is_true() {
case "${1:-}" in
1|true|TRUE|yes|YES|y|Y) return 0 ;;
*) return 1 ;;
esac
}
# Determine repo root relative to this script (script is expected to live in kubescape/build/)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
: "${RUN_E2E:=false}"
# Default to fatal E2E failures.
: "${E2E_FAIL_ON_ERROR:=1}"
log "Starting goreleaser post-build e2e script"
log "RUN_E2E=${RUN_E2E}"
log "E2E_FAIL_ON_ERROR=${E2E_FAIL_ON_ERROR}"
# Only run on linux/amd64 to avoid running multiple times (once per build)
# and to ensure we can run the binary on the current host (assuming host is amd64).
if [ -n "${GOARCH:-}" ] && [ "${GOARCH}" != "amd64" ]; then
log "Skipping smoke tests for non-amd64 build (GOARCH=${GOARCH})."
exit 0
fi
if ! is_true "${RUN_E2E}"; then
log "RUN_E2E is not enabled. Skipping smoke tests. (RUN_E2E=${RUN_E2E})"
exit 0
fi
# Locate the amd64 artifact in dist/.
# Goreleaser v2 puts binaries in dist/<id>_<os>_<arch>_<version>/<binary>
# Example: dist/cli_linux_amd64_v1/kubescape
ART_PATH=""
if [ -d "$REPO_ROOT/dist" ]; then
# Find any file named 'kubescape' inside a directory containing 'linux_amd64' inside 'dist'
# We use 'find' for robustness against varying directory names
ART_PATH=$(find "$REPO_ROOT/dist" -type f -name "kubescape" -path "*linux_amd64*" | head -n 1)
fi
if [ -z "$ART_PATH" ] || [ ! -f "$ART_PATH" ]; then
log "No kubescape artifact found in dist/ matching *linux_amd64*/kubescape. Skipping smoke tests."
# If we are supposed to run E2E, not finding the artifact is probably an error.
if is_true "${E2E_FAIL_ON_ERROR}"; then
log "E2E_FAIL_ON_ERROR enabled -> failing because artifact was not found."
exit 1
fi
exit 0
fi
log "Using artifact: $ART_PATH"
# Make binary executable if it is a binary
chmod +x "$ART_PATH" >/dev/null 2>&1 || true
# Locate python runner
PYTHON=""
if command -v python3 >/dev/null 2>&1; then
PYTHON=python3
elif command -v python >/dev/null 2>&1; then
PYTHON=python
fi
if [ -z "$PYTHON" ]; then
log "python3 (or python) not found in PATH."
if is_true "${E2E_FAIL_ON_ERROR}"; then
log "E2E_FAIL_ON_ERROR enabled -> failing the release because python is missing."
exit 2
else
log "E2E_FAIL_ON_ERROR disabled -> continuing without running tests."
exit 0
fi
fi
# Check for smoke test runner
SMOKE_RUNNER="$REPO_ROOT/smoke_testing/init.py"
if [ ! -f "$SMOKE_RUNNER" ]; then
log "Smoke test runner not found at $SMOKE_RUNNER"
if is_true "${E2E_FAIL_ON_ERROR}"; then
log "E2E_FAIL_ON_ERROR enabled -> failing the release because smoke runner is missing."
exit 3
else
log "E2E_FAIL_ON_ERROR disabled -> continuing without running tests."
exit 0
fi
fi
gha_group_start "Smoke tests"
log "Running smoke tests with $PYTHON $SMOKE_RUNNER \"$ART_PATH\""
# Run the test runner, propagate exit code
set +e
RELEASE="${RELEASE:-}" "$PYTHON" "$SMOKE_RUNNER" "$ART_PATH"
rc=$?
set -e
if [ $rc -eq 0 ]; then
log "Smoke tests passed (exit code 0)."
fi
log "Smoke tests exited with code: $rc"
gha_group_end
if [ $rc -ne 0 ]; then
if is_true "${E2E_FAIL_ON_ERROR}"; then
log "E2E_FAIL_ON_ERROR enabled -> failing the release (exit code $rc)."
exit $rc
else
log "E2E_FAIL_ON_ERROR disabled -> continuing despite test failures."
fi
fi
exit 0

View File

@@ -1,4 +1,4 @@
FROM gcr.io/distroless/static-debian13:debug-nonroot
FROM gcr.io/distroless/static-debian12:debug-nonroot
USER nonroot
WORKDIR /home/nonroot/
@@ -6,8 +6,7 @@ WORKDIR /home/nonroot/
ARG image_version client TARGETARCH
ENV RELEASE=$image_version CLIENT=$client
ARG TARGETPLATFORM
COPY $TARGETPLATFORM/kubescape /usr/bin/kubescape
COPY kubescape-${TARGETARCH}-ubuntu-latest /usr/bin/kubescape
RUN ["kubescape", "download", "artifacts"]
ENTRYPOINT ["kubescape"]

View File

@@ -26,7 +26,7 @@ var (
%[1]s list controls
Control documentation:
https://kubescape.io/docs/controls/
https://hub.armosec.io/docs/controls
`, cautils.ExecName())
)

View File

@@ -1,499 +0,0 @@
package mcpserver
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"time"
"github.com/kubescape/go-logger"
helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers"
"github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1"
spdxv1beta1 "github.com/kubescape/storage/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/spf13/cobra"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
type KubescapeMcpserver struct {
s *server.MCPServer
ksClient spdxv1beta1.SpdxV1beta1Interface
}
func createVulnerabilityToolsAndResources(ksServer *KubescapeMcpserver) {
// Tool to list vulnerability manifests
listManifestsTool := mcp.NewTool(
"list_vulnerability_manifests",
mcp.WithDescription("Discover available vulnerability manifests at image and workload levels"),
mcp.WithString("namespace",
mcp.Description("Filter by namespace (optional)"),
),
mcp.WithString("level",
mcp.Description("Type of vulnerability manifests to list"),
mcp.Enum("image", "workload", "both"),
),
)
ksServer.s.AddTool(listManifestsTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return ksServer.CallTool("list_vulnerability_manifests", request.Params.Arguments.(map[string]interface{}))
})
listVulnerabilitiesTool := mcp.NewTool(
"list_vulnerabilities_in_manifest",
mcp.WithDescription("List all vulnerabilities in a given manifest"),
mcp.WithString("namespace",
mcp.Description("Filter by namespace (optional)"),
),
mcp.WithString("manifest_name",
mcp.Required(),
mcp.Description("Name of the manifest to list vulnerabilities from"),
),
)
ksServer.s.AddTool(listVulnerabilitiesTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return ksServer.CallTool("list_vulnerabilities_in_manifest", request.Params.Arguments.(map[string]interface{}))
})
listVulnerabilityMatchesForCVE := mcp.NewTool(
"list_vulnerability_matches_for_cve",
mcp.WithDescription("List all vulnerability matches for a given CVE in a given manifest"),
mcp.WithString("namespace",
mcp.Description("Filter by namespace (optional)"),
),
mcp.WithString("manifest_name",
mcp.Required(),
mcp.Description("Name of the manifest to list vulnerabilities from"),
),
mcp.WithString("cve_id",
mcp.Required(),
mcp.Description("ID of the CVE to list matches for"),
),
)
ksServer.s.AddTool(listVulnerabilityMatchesForCVE, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return ksServer.CallTool("list_vulnerability_matches_for_cve", request.Params.Arguments.(map[string]interface{}))
})
vulnerabilityManifestTemplate := mcp.NewResourceTemplate(
"kubescape://vulnerability-manifests/{namespace}/{manifest_name}",
"Vulnerability Manifest",
mcp.WithTemplateDescription("Complete vulnerability manifest either for a specific workload or image. Use 'list_vulnerability_manifests' tool to discover available manifests."),
mcp.WithTemplateMIMEType("application/json"),
)
ksServer.s.AddResourceTemplate(vulnerabilityManifestTemplate, ksServer.ReadResource)
}
func createConfigurationsToolsAndResources(ksServer *KubescapeMcpserver) {
// Tool to list configuration manifests
listConfigsTool := mcp.NewTool(
"list_configuration_security_scan_manifests",
mcp.WithDescription("Discover available security configuration scan results at workload level (this returns a list of manifests, not the scan results themselves, to get the scan results, use the get_configuration_security_scan_manifest tool)"),
mcp.WithString("namespace",
mcp.Description("Filter by namespace (optional)"),
),
)
ksServer.s.AddTool(listConfigsTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return ksServer.CallTool("list_configuration_security_scan_manifests", request.Params.Arguments.(map[string]interface{}))
})
getConfigDetailsTool := mcp.NewTool(
"get_configuration_security_scan_manifest",
mcp.WithDescription("Get details of a specific security configuration scan result"),
mcp.WithString("namespace",
mcp.Description("Namespace of the manifest (optional, defaults to 'kubescape')"),
),
mcp.WithString("manifest_name",
mcp.Required(),
mcp.Description("Name of the configuration manifest to get details for (get this from the list_configuration_security_scan_manifests tool)"),
),
)
ksServer.s.AddTool(getConfigDetailsTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return ksServer.CallTool("get_configuration_security_scan_manifest", request.Params.Arguments.(map[string]interface{}))
})
configManifestTemplate := mcp.NewResourceTemplate(
"kubescape://configuration-manifests/{namespace}/{manifest_name}",
"Configuration Security Scan Manifest",
mcp.WithTemplateDescription("Complete configuration scan manifest for a specific workload. Use 'list_configuration_security_scan_manifests' tool to discover available manifests."),
mcp.WithTemplateMIMEType("application/json"),
)
ksServer.s.AddResourceTemplate(configManifestTemplate, ksServer.ReadConfigurationResource)
}
func (ksServer *KubescapeMcpserver) ReadResource(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
uri := request.Params.URI
// Validate the URI and check if it starts with kubescape://vulnerability-manifests/
if !strings.HasPrefix(uri, "kubescape://vulnerability-manifests/") {
return nil, fmt.Errorf("invalid URI: %s", uri)
}
// Verify that the URI is either the CVE list or CVE details
if !strings.HasSuffix(uri, "/cve_list") && !strings.Contains(uri, "/cve_details/") {
return nil, fmt.Errorf("invalid URI: %s", uri)
}
// Split the URI into namespace and manifest name
parts := strings.Split(uri, "/")
if len(parts) != 4 && len(parts) != 5 {
return nil, fmt.Errorf("invalid URI: %s", uri)
}
namespace := parts[1]
manifestName := parts[2]
cveID := ""
if len(parts) == 5 {
cveID = parts[3]
}
// Get the vulnerability manifest
manifest, err := ksServer.ksClient.VulnerabilityManifests(namespace).Get(ctx, manifestName, metav1.GetOptions{})
if err != nil {
return nil, fmt.Errorf("failed to get vulnerability manifest: %s", err)
}
var responseJson []byte
if cveID == "" {
// CVE list
var cveList []v1beta1.Vulnerability
for _, match := range manifest.Spec.Payload.Matches {
cveList = append(cveList, match.Vulnerability)
}
responseJson, err = json.Marshal(cveList)
if err != nil {
return nil, fmt.Errorf("failed to marshal cve list: %s", err)
}
} else {
// CVE details
var match []v1beta1.Match
for _, m := range manifest.Spec.Payload.Matches {
if m.Vulnerability.ID == cveID {
match = append(match, m)
}
}
responseJson, err = json.Marshal(match)
if err != nil {
return nil, fmt.Errorf("failed to marshal cve details: %s", err)
}
}
return []mcp.ResourceContents{mcp.TextResourceContents{
URI: uri,
Text: string(responseJson),
}}, nil
}
func (ksServer *KubescapeMcpserver) ReadConfigurationResource(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
uri := request.Params.URI
if !strings.HasPrefix(uri, "kubescape://configuration-manifests/") {
return nil, fmt.Errorf("invalid URI: %s", uri)
}
parts := strings.Split(uri[len("kubescape://configuration-manifests/"):], "/")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid URI: %s", uri)
}
namespace := parts[0]
manifestName := parts[1]
manifest, err := ksServer.ksClient.WorkloadConfigurationScans(namespace).Get(ctx, manifestName, metav1.GetOptions{})
if err != nil {
return nil, fmt.Errorf("failed to get configuration manifest: %s", err)
}
responseJson, err := json.Marshal(manifest)
if err != nil {
return nil, fmt.Errorf("failed to marshal configuration manifest: %s", err)
}
return []mcp.ResourceContents{mcp.TextResourceContents{
URI: uri,
Text: string(responseJson),
}}, nil
}
func (ksServer *KubescapeMcpserver) CallTool(name string, arguments map[string]interface{}) (*mcp.CallToolResult, error) {
switch name {
case "list_vulnerability_manifests":
//namespace, ok := arguments["namespace"]
//if !ok {
// namespace = ""
//}
level, ok := arguments["level"]
if !ok {
level = "both"
}
result := map[string]interface{}{
"vulnerability_manifests": map[string]interface{}{},
}
// Get workload-level manifests
labelSelector := ""
switch level {
case "workload":
labelSelector = "kubescape.io/context=filtered"
case "image":
labelSelector = "kubescape.io/context=non-filtered"
}
var manifests *v1beta1.VulnerabilityManifestList
var err error
if labelSelector == "" {
manifests, err = ksServer.ksClient.VulnerabilityManifests(metav1.NamespaceAll).List(context.Background(), metav1.ListOptions{})
} else {
manifests, err = ksServer.ksClient.VulnerabilityManifests(metav1.NamespaceAll).List(context.Background(), metav1.ListOptions{
LabelSelector: labelSelector,
})
}
if err != nil {
return nil, err
}
log.Printf("Found %d manifests", len(manifests.Items))
vulnerabilityManifests := []map[string]interface{}{}
for _, manifest := range manifests.Items {
isImageLevel := manifest.Annotations[helpersv1.WlidMetadataKey] == ""
manifestMap := map[string]interface{}{
"type": "workload",
"namespace": manifest.Namespace,
"manifest_name": manifest.Name,
"image-level": isImageLevel,
"workload-level": !isImageLevel,
"image-id": manifest.Annotations[helpersv1.ImageIDMetadataKey],
"image-tag": manifest.Annotations[helpersv1.ImageTagMetadataKey],
"workload-id": manifest.Annotations[helpersv1.WlidMetadataKey],
"workload-container-name": manifest.Annotations[helpersv1.ContainerNameMetadataKey],
"resource_uri": fmt.Sprintf("kubescape://vulnerability-manifests/%s/%s",
manifest.Namespace, manifest.Name),
}
vulnerabilityManifests = append(vulnerabilityManifests, manifestMap)
}
result["vulnerability_manifests"].(map[string]interface{})["manifests"] = vulnerabilityManifests
// Add template information
result["available_templates"] = map[string]string{
"vulnerability_manifest_cve_list": "kubescape://vulnerability-manifests/{namespace}/{manifest_name}/cve_list",
"vulnerability_manifest_cve_details": "kubescape://vulnerability-manifests/{namespace}/{manifest_name}/cve_details/{cve_id}",
}
content, _ := json.Marshal(result)
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
Text: string(content),
},
},
}, nil
case "list_vulnerabilities_in_manifest":
namespace, ok := arguments["namespace"]
if !ok {
namespace = "kubescape"
}
namespaceStr, ok := namespace.(string)
if !ok {
return nil, fmt.Errorf("namespace must be a string")
}
manifestName, ok := arguments["manifest_name"]
if !ok {
return nil, fmt.Errorf("manifest_name is required")
}
manifestNameStr, ok := manifestName.(string)
if !ok {
return nil, fmt.Errorf("manifest_name must be a string")
}
manifest, err := ksServer.ksClient.VulnerabilityManifests(namespaceStr).Get(context.Background(), manifestNameStr, metav1.GetOptions{})
if err != nil {
return nil, fmt.Errorf("failed to get vulnerability manifest: %s", err)
}
var cveList []v1beta1.Vulnerability
for _, match := range manifest.Spec.Payload.Matches {
cveList = append(cveList, match.Vulnerability)
}
responseJson, err := json.Marshal(cveList)
if err != nil {
return nil, fmt.Errorf("failed to marshal cve list: %s", err)
}
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
Text: string(responseJson),
},
},
}, nil
case "list_vulnerability_matches_for_cve":
namespace, ok := arguments["namespace"]
if !ok {
namespace = "kubescape"
}
namespaceStr, ok := namespace.(string)
if !ok {
return nil, fmt.Errorf("namespace must be a string")
}
manifestName, ok := arguments["manifest_name"]
if !ok {
return nil, fmt.Errorf("manifest_name is required")
}
manifestNameStr, ok := manifestName.(string)
if !ok {
return nil, fmt.Errorf("manifest_name must be a string")
}
cveID, ok := arguments["cve_id"]
if !ok {
return nil, fmt.Errorf("cve_id is required")
}
cveIDStr, ok := cveID.(string)
if !ok {
return nil, fmt.Errorf("cve_id must be a string")
}
manifest, err := ksServer.ksClient.VulnerabilityManifests(namespaceStr).Get(context.Background(), manifestNameStr, metav1.GetOptions{})
if err != nil {
return nil, fmt.Errorf("failed to get vulnerability manifest: %s", err)
}
var match []v1beta1.Match
for _, m := range manifest.Spec.Payload.Matches {
if m.Vulnerability.ID == cveIDStr {
match = append(match, m)
}
}
responseJson, err := json.Marshal(match)
if err != nil {
return nil, fmt.Errorf("failed to marshal cve details: %s", err)
}
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
Text: string(responseJson),
},
},
}, nil
case "list_configuration_security_scan_manifests":
namespace, ok := arguments["namespace"]
if !ok {
namespace = "kubescape"
}
namespaceStr, ok := namespace.(string)
if !ok {
return nil, fmt.Errorf("namespace must be a string")
}
manifests, err := ksServer.ksClient.WorkloadConfigurationScans(namespaceStr).List(context.Background(), metav1.ListOptions{})
if err != nil {
return nil, err
}
log.Printf("Found %d configuration manifests", len(manifests.Items))
configManifests := []map[string]interface{}{}
for _, manifest := range manifests.Items {
item := map[string]interface{}{
"namespace": manifest.Namespace,
"manifest_name": manifest.Name,
"resource_uri": fmt.Sprintf("kubescape://configuration-manifests/%s/%s", manifest.Namespace, manifest.Name),
}
configManifests = append(configManifests, item)
}
result := map[string]interface{}{
"configuration_manifests": map[string]interface{}{
"manifests": configManifests,
},
"available_templates": map[string]string{
"configuration_manifest_details": "kubescape://configuration-manifests/{namespace}/{manifest_name}",
},
}
content, _ := json.Marshal(result)
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
Text: string(content),
},
},
}, nil
case "get_configuration_security_scan_manifest":
namespace, ok := arguments["namespace"]
if !ok {
namespace = "kubescape"
}
namespaceStr, ok := namespace.(string)
if !ok {
return nil, fmt.Errorf("namespace must be a string")
}
manifestName, ok := arguments["manifest_name"]
if !ok {
return nil, fmt.Errorf("manifest_name is required")
}
manifestNameStr, ok := manifestName.(string)
if !ok {
return nil, fmt.Errorf("manifest_name must be a string")
}
manifest, err := ksServer.ksClient.WorkloadConfigurationScans(namespaceStr).Get(context.Background(), manifestNameStr, metav1.GetOptions{})
if err != nil {
return nil, fmt.Errorf("failed to get configuration manifest: %s", err)
}
responseJson, err := json.Marshal(manifest)
if err != nil {
return nil, fmt.Errorf("failed to marshal configuration manifest: %s", err)
}
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
Text: string(responseJson),
},
},
}, nil
default:
return nil, fmt.Errorf("unknown tool: %s", name)
}
}
func mcpServerEntrypoint() error {
logger.L().Info("Starting MCP server...")
// Create a kubernetes client and verify it's working
client, err := CreateKsObjectConnection("default", 10*time.Second)
if err != nil {
return fmt.Errorf("failed to create kubernetes client: %v", err)
}
// Create a new MCP server
s := server.NewMCPServer(
"Kubescape MCP Server",
"0.0.1",
server.WithToolCapabilities(false),
server.WithRecovery(),
)
ksServer := &KubescapeMcpserver{
s: s,
ksClient: client,
}
// Creating Kubescape tools and resources
createVulnerabilityToolsAndResources(ksServer)
createConfigurationsToolsAndResources(ksServer)
// Start the server
if err := server.ServeStdio(s); err != nil {
return fmt.Errorf("server error: %v", err)
}
return nil
}
func GetMCPServerCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "mcpserver",
Short: "Start the Kubescape MCP server",
Long: `Start the Kubescape MCP server`,
RunE: func(cmd *cobra.Command, args []string) error {
return mcpServerEntrypoint()
},
}
return cmd
}

View File

@@ -1,14 +0,0 @@
package mcpserver
import (
"time"
"github.com/kubescape/kubescape/v3/pkg/ksinit"
spdxv1beta1 "github.com/kubescape/storage/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1"
)
// CreateKsObjectConnection delegates to the shared ksinit package
func CreateKsObjectConnection(namespace string, maxElapsedTime time.Duration) (spdxv1beta1.SpdxV1beta1Interface, error) {
return ksinit.CreateKsObjectConnection(namespace, maxElapsedTime)
}

View File

@@ -14,7 +14,7 @@ const (
)
var operatorExamples = fmt.Sprintf(`
# Trigger a configuration scan
%[1]s operator scan configurations
@@ -34,16 +34,16 @@ func GetOperatorCmd(ks meta.IKubescape) *cobra.Command {
Args: func(cmd *cobra.Command, args []string) error {
operatorInfo.Subcommands = append(operatorInfo.Subcommands, "operator")
if len(args) < 2 {
return errors.New("for the operator sub-command, you need to provide at least one additional sub-command. Refer to the examples above")
return errors.New("For the operator sub-command, you need to provide at least one additional sub-command. Refer to the examples above.")
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) < 2 {
return errors.New("for the operator sub-command, you need to provide at least one additional sub-command. Refer to the examples above")
return errors.New("For the operator sub-command, you need to provide at least one additional sub-command. Refer to the examples above.")
}
if args[0] != scanSubCommand {
return fmt.Errorf("for the operator sub-command, only %s is supported. Refer to the examples above", scanSubCommand)
return errors.New(fmt.Sprintf("For the operator sub-command, only %s is supported. Refer to the examples above.", scanSubCommand))
}
return nil
},

View File

@@ -21,7 +21,7 @@ func TestGetOperatorCmd(t *testing.T) {
assert.Equal(t, operatorExamples, cmd.Example)
err := cmd.Args(&cobra.Command{}, []string{})
expectedErrorMessage := "for the operator sub-command, you need to provide at least one additional sub-command. Refer to the examples above"
expectedErrorMessage := "For the operator sub-command, you need to provide at least one additional sub-command. Refer to the examples above."
assert.Equal(t, expectedErrorMessage, err.Error())
err = cmd.Args(&cobra.Command{}, []string{"scan", "configurations"})
@@ -37,6 +37,6 @@ func TestGetOperatorCmd(t *testing.T) {
assert.Equal(t, expectedErrorMessage, err.Error())
err = cmd.RunE(&cobra.Command{}, []string{"random-subcommand", "random-config"})
expectedErrorMessage = "for the operator sub-command, only " + scanSubCommand + " is supported. Refer to the examples above"
expectedErrorMessage = "For the operator sub-command, only " + scanSubCommand + " is supported. Refer to the examples above."
assert.Equal(t, expectedErrorMessage, err.Error())
}

View File

@@ -32,7 +32,7 @@ func getOperatorScanCmd(ks meta.IKubescape, operatorInfo cautils.OperatorInfo) *
return errors.New("for operator scan sub command, you must pass at least 1 more sub commands, see above examples")
}
if (args[0] != vulnerabilitiesSubCommand) && (args[0] != configurationsSubCommand) {
return fmt.Errorf("for the operator sub-command, only %s and %s are supported. Refer to the examples above", vulnerabilitiesSubCommand, configurationsSubCommand)
return errors.New(fmt.Sprintf("For the operator sub-command, only %s and %s are supported. Refer to the examples above.", vulnerabilitiesSubCommand, configurationsSubCommand))
}
return nil
},

View File

@@ -41,6 +41,6 @@ func TestGetOperatorScanCmd(t *testing.T) {
assert.Nil(t, err)
err = cmd.RunE(&cobra.Command{}, []string{"random"})
expectedErrorMessage = "for the operator sub-command, only " + vulnerabilitiesSubCommand + " and " + configurationsSubCommand + " are supported. Refer to the examples above"
expectedErrorMessage = "For the operator sub-command, only " + vulnerabilitiesSubCommand + " and " + configurationsSubCommand + " are supported. Refer to the examples above."
assert.Equal(t, expectedErrorMessage, err.Error())
}

View File

@@ -73,7 +73,7 @@ We will demonstrate how to use the patch command with an example of [nginx](http
sudo buildkitd
```
2. In a separate terminal, run the `kubescape patch` command:
2. In a seperate terminal, run the `kubescape patch` command:
```bash
sudo kubescape patch --image docker.io/library/nginx:1.22

View File

@@ -6,12 +6,13 @@ import (
"strings"
"time"
"github.com/distribution/reference"
"github.com/docker/distribution/reference"
"github.com/kubescape/go-logger"
"github.com/kubescape/kubescape/v3/cmd/shared"
"github.com/kubescape/kubescape/v3/core/cautils"
"github.com/kubescape/kubescape/v3/core/meta"
metav1 "github.com/kubescape/kubescape/v3/core/meta/datastructures/v1"
"github.com/kubescape/kubescape/v3/pkg/imagescan"
"github.com/spf13/cobra"
)
@@ -27,7 +28,6 @@ var patchCmdExamples = fmt.Sprintf(`
func GetPatchCmd(ks meta.IKubescape) *cobra.Command {
var patchInfo metav1.PatchInfo
var scanInfo cautils.ScanInfo
var useDefaultMatchers bool
patchCmd := &cobra.Command{
Use: "patch --image <image>:<tag> [flags]",
@@ -49,15 +49,12 @@ func GetPatchCmd(ks meta.IKubescape) *cobra.Command {
return err
}
// Set the UseDefaultMatchers field in scanInfo
scanInfo.UseDefaultMatchers = useDefaultMatchers
exceedsSeverityThreshold, err := ks.Patch(&patchInfo, &scanInfo)
results, err := ks.Patch(&patchInfo, &scanInfo)
if err != nil {
return err
}
if exceedsSeverityThreshold {
if imagescan.ExceedsSeverityThreshold(results, imagescan.ParseSeverity(scanInfo.FailThresholdSeverity)) {
shared.TerminateOnExceedingSeverity(&scanInfo, logger.L())
}
@@ -79,7 +76,6 @@ func GetPatchCmd(ks meta.IKubescape) *cobra.Command {
patchCmd.PersistentFlags().BoolVarP(&scanInfo.VerboseMode, "verbose", "v", false, "Display full report. Default to false")
patchCmd.PersistentFlags().StringVarP(&scanInfo.FailThresholdSeverity, "severity-threshold", "s", "", "Severity threshold is the severity of a vulnerability at which the command fails and returns exit code 1")
patchCmd.PersistentFlags().BoolVarP(&useDefaultMatchers, "use-default-matchers", "", true, "Use default matchers (true) or CPE matchers (false) for image scanning")
return patchCmd
}

View File

@@ -3,8 +3,6 @@ package patch
import (
"testing"
metav1 "github.com/kubescape/kubescape/v3/core/meta/datastructures/v1"
"github.com/kubescape/kubescape/v3/core/mocks"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
@@ -52,18 +50,3 @@ func TestGetPatchCmdWithNonExistentImage(t *testing.T) {
assert.Error(t, err)
assert.Equal(t, expectedErrorMessage, err.Error())
}
func Test_validateImagePatchInfo_EmptyImage(t *testing.T) {
patchInfo := &metav1.PatchInfo{}
err := validateImagePatchInfo(patchInfo)
assert.NotNil(t, err)
assert.Equal(t, "image tag is required", err.Error())
}
func Test_validateImagePatchInfo_Image(t *testing.T) {
patchInfo := &metav1.PatchInfo{
Image: "testing",
}
err := validateImagePatchInfo(patchInfo)
assert.Nil(t, err)
}

View File

@@ -13,7 +13,6 @@ import (
"github.com/kubescape/kubescape/v3/cmd/download"
"github.com/kubescape/kubescape/v3/cmd/fix"
"github.com/kubescape/kubescape/v3/cmd/list"
"github.com/kubescape/kubescape/v3/cmd/mcpserver"
"github.com/kubescape/kubescape/v3/cmd/operator"
"github.com/kubescape/kubescape/v3/cmd/patch"
"github.com/kubescape/kubescape/v3/cmd/prerequisites"
@@ -44,16 +43,16 @@ var ksExamples = fmt.Sprintf(`
%[1]s config view
`, cautils.ExecName())
func NewDefaultKubescapeCommand(ctx context.Context, ksVersion, ksCommit, ksDate string) *cobra.Command {
func NewDefaultKubescapeCommand(ctx context.Context) *cobra.Command {
ks := core.NewKubescape(ctx)
return getRootCmd(ks, ksVersion, ksCommit, ksDate)
return getRootCmd(ks)
}
func getRootCmd(ks meta.IKubescape, ksVersion, ksCommit, ksDate string) *cobra.Command {
func getRootCmd(ks meta.IKubescape) *cobra.Command {
rootCmd := &cobra.Command{
Use: "kubescape",
Short: "Kubescape is a tool for testing Kubernetes security posture. Docs: https://kubescape.io/docs/",
Short: "Kubescape is a tool for testing Kubernetes security posture. Docs: https://hub.armosec.io/docs",
Example: ksExamples,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
k8sinterface.SetClusterContextName(rootInfo.KubeContext)
@@ -93,7 +92,7 @@ func getRootCmd(ks meta.IKubescape, ksVersion, ksCommit, ksDate string) *cobra.C
rootCmd.AddCommand(download.GetDownloadCmd(ks))
rootCmd.AddCommand(list.GetListCmd(ks))
rootCmd.AddCommand(completion.GetCompletionCmd())
rootCmd.AddCommand(version.GetVersionCmd(ks, ksVersion, ksCommit, ksDate))
rootCmd.AddCommand(version.GetVersionCmd(ks))
rootCmd.AddCommand(config.GetConfigCmd(ks))
rootCmd.AddCommand(update.GetUpdateCmd(ks))
rootCmd.AddCommand(fix.GetFixCmd(ks))
@@ -101,7 +100,6 @@ func getRootCmd(ks meta.IKubescape, ksVersion, ksCommit, ksDate string) *cobra.C
rootCmd.AddCommand(vap.GetVapHelperCmd())
rootCmd.AddCommand(operator.GetOperatorCmd(ks))
rootCmd.AddCommand(prerequisites.GetPreReqCmd(ks))
rootCmd.AddCommand(mcpserver.GetMCPServerCmd())
// deprecated commands
rootCmd.AddCommand(&cobra.Command{
@@ -116,7 +114,7 @@ func getRootCmd(ks meta.IKubescape, ksVersion, ksCommit, ksDate string) *cobra.C
return rootCmd
}
func Execute(ctx context.Context, ksVersion, ksCommit, ksDate string) error {
ks := NewDefaultKubescapeCommand(ctx, ksVersion, ksCommit, ksDate)
func Execute(ctx context.Context) error {
ks := NewDefaultKubescapeCommand(ctx)
return ks.Execute()
}
}

View File

@@ -1,24 +0,0 @@
package cmd
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewDefaultKubescapeCommand(t *testing.T) {
t.Run("NewDefaultKubescapeCommand", func(t *testing.T) {
cmd := NewDefaultKubescapeCommand(context.Background(), "", "", "")
assert.NotNil(t, cmd)
})
}
func TestExecute(t *testing.T) {
t.Run("Execute", func(t *testing.T) {
err := Execute(context.Background(), "", "", "")
if err != nil {
assert.EqualErrorf(t, err, "unknown command \"^\\\\QTestExecute\\\\E$\" for \"kubescape\"", err.Error())
}
})
}

View File

@@ -29,7 +29,7 @@ var (
Run '%[1]s list controls' for the list of supported controls
Control documentation:
https://kubescape.io/docs/controls/
https://hub.armosec.io/docs/controls
`, cautils.ExecName())
)
@@ -99,7 +99,7 @@ func getControlCmd(ks meta.IKubescape, scanInfo *cautils.ScanInfo) *cobra.Comman
if err != nil {
logger.L().Fatal(err.Error())
}
if err := results.HandleResults(ks.Context(), scanInfo); err != nil {
if err := results.HandleResults(ks.Context()); err != nil {
logger.L().Fatal(err.Error())
}
if !scanInfo.VerboseMode {

View File

@@ -117,7 +117,7 @@ func getFrameworkCmd(ks meta.IKubescape, scanInfo *cautils.ScanInfo) *cobra.Comm
logger.L().Fatal(err.Error())
}
if err = results.HandleResults(ks.Context(), scanInfo); err != nil {
if err = results.HandleResults(ks.Context()); err != nil {
logger.L().Fatal(err.Error())
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/kubescape/kubescape/v3/core/cautils"
"github.com/kubescape/kubescape/v3/core/meta"
metav1 "github.com/kubescape/kubescape/v3/core/meta/datastructures/v1"
"github.com/kubescape/kubescape/v3/pkg/imagescan"
"github.com/spf13/cobra"
)
@@ -32,7 +33,6 @@ var (
func getImageCmd(ks meta.IKubescape, scanInfo *cautils.ScanInfo) *cobra.Command {
var imgCredentials shared.ImageCredentials
var exceptions string
var useDefaultMatchers bool
cmd := &cobra.Command{
Use: "image <image>:<tag> [flags]",
@@ -54,19 +54,18 @@ func getImageCmd(ks meta.IKubescape, scanInfo *cautils.ScanInfo) *cobra.Command
}
imgScanInfo := &metav1.ImageScanInfo{
Image: args[0],
Username: imgCredentials.Username,
Password: imgCredentials.Password,
Exceptions: exceptions,
UseDefaultMatchers: useDefaultMatchers,
Image: args[0],
Username: imgCredentials.Username,
Password: imgCredentials.Password,
Exceptions: exceptions,
}
exceedsSeverityThreshold, err := ks.ScanImage(imgScanInfo, scanInfo)
results, err := ks.ScanImage(imgScanInfo, scanInfo)
if err != nil {
return err
}
if exceedsSeverityThreshold {
if imagescan.ExceedsSeverityThreshold(results, imagescan.ParseSeverity(scanInfo.FailThresholdSeverity)) {
shared.TerminateOnExceedingSeverity(scanInfo, logger.L())
}
@@ -78,7 +77,6 @@ func getImageCmd(ks meta.IKubescape, scanInfo *cautils.ScanInfo) *cobra.Command
cmd.PersistentFlags().StringVarP(&exceptions, "exceptions", "", "", "Path to the exceptions file")
cmd.PersistentFlags().StringVarP(&imgCredentials.Username, "username", "u", "", "Username for registry login")
cmd.PersistentFlags().StringVarP(&imgCredentials.Password, "password", "p", "", "Password for registry login")
cmd.PersistentFlags().BoolVarP(&useDefaultMatchers, "use-default-matchers", "", true, "Use default matchers (true) or CPE matchers (false)")
return cmd
}

View File

@@ -92,8 +92,6 @@ func GetScanCommand(ks meta.IKubescape) *cobra.Command {
scanCmd.PersistentFlags().BoolVarP(&scanInfo.PrintAttackTree, "print-attack-tree", "", false, "Print attack tree")
scanCmd.PersistentFlags().BoolVarP(&scanInfo.EnableRegoPrint, "enable-rego-prints", "", false, "Enable sending to rego prints to the logs (use with debug log level: -l debug)")
scanCmd.PersistentFlags().BoolVarP(&scanInfo.ScanImages, "scan-images", "", false, "Scan resources images")
scanCmd.PersistentFlags().BoolVarP(&scanInfo.UseDefaultMatchers, "use-default-matchers", "", true, "Use default matchers (true) or CPE matchers (false) for image scanning")
scanCmd.PersistentFlags().StringSliceVar(&scanInfo.LabelsToCopy, "labels-to-copy", nil, "Labels to copy from workloads to scan reports for easy identification. e.g: --labels-to-copy=app,team,environment")
scanCmd.PersistentFlags().MarkDeprecated("fail-threshold", "use '--compliance-threshold' flag instead. Flag will be removed at 1.Dec.2023")
scanCmd.PersistentFlags().MarkDeprecated("create-account", "Create account is no longer supported. In case of a missing Account ID and a configured backend server, a new account id will be generated automatically by Kubescape. Feel free to contact the Kubescape maintainers for more information.")
@@ -141,7 +139,7 @@ func securityScan(scanInfo cautils.ScanInfo, ks meta.IKubescape) error {
return err
}
if err = results.HandleResults(ks.Context(), &scanInfo); err != nil {
if err = results.HandleResults(ks.Context()); err != nil {
return err
}

View File

@@ -5,7 +5,6 @@ import (
"os"
"reflect"
"testing"
"time"
"github.com/kubescape/go-logger/helpers"
"github.com/kubescape/kubescape/v3/cmd/shared"
@@ -187,23 +186,20 @@ type spyLogger struct {
setItems []spyLogMessage
}
var _ helpers.ILogger = &spyLogger{}
func (l *spyLogger) Error(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) Success(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) Warning(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) Info(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) Debug(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) SetLevel(level string) error { return nil }
func (l *spyLogger) GetLevel() string { return "" }
func (l *spyLogger) SetWriter(w *os.File) {}
func (l *spyLogger) GetWriter() *os.File { return &os.File{} }
func (l *spyLogger) LoggerName() string { return "" }
func (l *spyLogger) Ctx(_ context.Context) helpers.ILogger { return l }
func (l *spyLogger) Start(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) StopSuccess(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) StopError(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) TimedWrapper(funcName string, timeout time.Duration, task func()) {}
func (l *spyLogger) Error(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) Success(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) Warning(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) Info(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) Debug(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) SetLevel(level string) error { return nil }
func (l *spyLogger) GetLevel() string { return "" }
func (l *spyLogger) SetWriter(w *os.File) {}
func (l *spyLogger) GetWriter() *os.File { return &os.File{} }
func (l *spyLogger) LoggerName() string { return "" }
func (l *spyLogger) Ctx(_ context.Context) helpers.ILogger { return l }
func (l *spyLogger) Start(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) StopSuccess(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) StopError(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) Fatal(msg string, details ...helpers.IDetails) {
firstDetail := details[0]

View File

@@ -35,7 +35,7 @@ func Test_validateControlScanInfo(t *testing.T) {
t.Run(
tc.Description,
func(t *testing.T) {
var want = tc.Want
var want error = tc.Want
got := validateControlScanInfo(tc.ScanInfo)
@@ -85,7 +85,7 @@ func Test_validateFrameworkScanInfo(t *testing.T) {
t.Run(
tc.Description,
func(t *testing.T) {
var want = tc.Want
var want error = tc.Want
got := validateFrameworkScanInfo(tc.ScanInfo)

View File

@@ -70,7 +70,7 @@ func getWorkloadCmd(ks meta.IKubescape, scanInfo *cautils.ScanInfo) *cobra.Comma
logger.L().Fatal(err.Error())
}
if err = results.HandleResults(ks.Context(), scanInfo); err != nil {
if err = results.HandleResults(ks.Context()); err != nil {
logger.L().Fatal(err.Error())
}
@@ -95,7 +95,7 @@ func setWorkloadScanInfo(scanInfo *cautils.ScanInfo, kind string, name string) {
scanInfo.ScanObject.SetKind(kind)
scanInfo.ScanObject.SetName(name)
scanInfo.SetPolicyIdentifiers([]string{"workloadscan", "allcontrols"}, v1.KindFramework)
scanInfo.SetPolicyIdentifiers([]string{"workloadscan"}, v1.KindFramework)
if scanInfo.FilePath != "" {
scanInfo.InputPatterns = []string{scanInfo.FilePath}

View File

@@ -28,10 +28,6 @@ func TestSetWorkloadScanInfo(t *testing.T) {
Identifier: "workloadscan",
Kind: v1.KindFramework,
},
{
Identifier: "allcontrols",
Kind: v1.KindFramework,
},
},
ScanType: cautils.ScanTypeWorkload,
ScanObject: &objectsenvelopes.ScanObject{
@@ -63,19 +59,12 @@ func TestSetWorkloadScanInfo(t *testing.T) {
t.Errorf("got: %v, want: %v", scanInfo.ScanObject.Metadata.Name, tc.want.ScanObject.Metadata.Name)
}
if len(scanInfo.PolicyIdentifier) != len(tc.want.PolicyIdentifier) {
t.Errorf("got: %v policy identifiers, want: %v", len(scanInfo.PolicyIdentifier), len(tc.want.PolicyIdentifier))
if len(scanInfo.PolicyIdentifier) != 1 {
t.Errorf("got: %v, want: %v", len(scanInfo.PolicyIdentifier), 1)
}
for i, wantPolicy := range tc.want.PolicyIdentifier {
if i < len(scanInfo.PolicyIdentifier) {
if scanInfo.PolicyIdentifier[i].Identifier != wantPolicy.Identifier {
t.Errorf("got: %v, want: %v", scanInfo.PolicyIdentifier[i].Identifier, wantPolicy.Identifier)
}
if scanInfo.PolicyIdentifier[i].Kind != wantPolicy.Kind {
t.Errorf("got: %v, want: %v", scanInfo.PolicyIdentifier[i].Kind, wantPolicy.Kind)
}
}
if scanInfo.PolicyIdentifier[0].Identifier != tc.want.PolicyIdentifier[0].Identifier {
t.Errorf("got: %v, want: %v", scanInfo.PolicyIdentifier[0].Identifier, tc.want.PolicyIdentifier[0].Identifier)
}
},
)
@@ -105,17 +94,3 @@ func TestGetWorkloadCmd_ChartPathAndFilePathEmpty(t *testing.T) {
expectedErrorMessage = "invalid workload identifier"
assert.Equal(t, expectedErrorMessage, err.Error())
}
func Test_parseWorkloadIdentifierString_Empty(t *testing.T) {
t.Run("empty identifier", func(t *testing.T) {
_, _, err := parseWorkloadIdentifierString("")
assert.Error(t, err)
})
}
func Test_parseWorkloadIdentifierString_NoError(t *testing.T) {
t.Run("valid identifier", func(t *testing.T) {
_, _, err := parseWorkloadIdentifierString("default/Deployment")
assert.NoError(t, err)
})
}

View File

@@ -50,7 +50,7 @@ func TestValidateImageScanInfo(t *testing.T) {
t.Run(
tc.Description,
func(t *testing.T) {
var want = tc.Want
var want error = tc.Want
got := ValidateImageScanInfo(tc.ScanInfo)

View File

@@ -5,7 +5,6 @@ import (
"os"
"reflect"
"testing"
"time"
"github.com/kubescape/go-logger/helpers"
"github.com/kubescape/kubescape/v3/core/cautils"
@@ -21,23 +20,20 @@ type spyLogger struct {
setItems []spyLogMessage
}
var _ helpers.ILogger = &spyLogger{}
func (l *spyLogger) Error(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) Success(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) Warning(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) Info(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) Debug(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) SetLevel(level string) error { return nil }
func (l *spyLogger) GetLevel() string { return "" }
func (l *spyLogger) SetWriter(w *os.File) {}
func (l *spyLogger) GetWriter() *os.File { return &os.File{} }
func (l *spyLogger) LoggerName() string { return "" }
func (l *spyLogger) Ctx(_ context.Context) helpers.ILogger { return l }
func (l *spyLogger) Start(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) StopSuccess(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) StopError(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) TimedWrapper(funcName string, timeout time.Duration, task func()) {}
func (l *spyLogger) Error(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) Success(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) Warning(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) Info(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) Debug(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) SetLevel(level string) error { return nil }
func (l *spyLogger) GetLevel() string { return "" }
func (l *spyLogger) SetWriter(w *os.File) {}
func (l *spyLogger) GetWriter() *os.File { return &os.File{} }
func (l *spyLogger) LoggerName() string { return "" }
func (l *spyLogger) Ctx(_ context.Context) helpers.ILogger { return l }
func (l *spyLogger) Start(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) StopSuccess(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) StopError(msg string, details ...helpers.IDetails) {}
func (l *spyLogger) Fatal(msg string, details ...helpers.IDetails) {
firstDetail := details[0]

View File

@@ -1,18 +0,0 @@
package update
import (
"context"
"testing"
"github.com/kubescape/kubescape/v3/core/core"
"github.com/stretchr/testify/assert"
)
func TestGetUpdateCmd(t *testing.T) {
ks := core.NewKubescape(context.TODO())
cmd := GetUpdateCmd(ks)
assert.NotNil(t, cmd)
err := cmd.RunE(cmd, []string{})
assert.Nil(t, err)
}

View File

@@ -220,11 +220,9 @@ func createPolicyBinding(bindingName string, policyName string, action string, p
}
policyBinding.Spec.ValidationActions = []admissionv1.ValidationAction{admissionv1.ValidationAction(action)}
paramAction := admissionv1.DenyAction
if paramRefName != "" {
policyBinding.Spec.ParamRef = &admissionv1.ParamRef{
Name: paramRefName,
ParameterNotFoundAction: &paramAction,
Name: paramRefName,
}
}
// Marshal the policy binding to YAML

View File

@@ -9,29 +9,24 @@ import (
"github.com/spf13/cobra"
)
func GetVersionCmd(ks meta.IKubescape, version, commit, date string) *cobra.Command {
func GetVersionCmd(ks meta.IKubescape) *cobra.Command {
versionCmd := &cobra.Command{
Use: "version",
Short: "Get current version",
Long: ``,
RunE: func(cmd *cobra.Command, args []string) error {
v := versioncheck.NewIVersionCheckHandler(ks.Context())
_ = v.CheckLatestVersion(ks.Context(), versioncheck.NewVersionCheckRequest("", version, "", "", "version", nil))
versionCheckRequest := versioncheck.NewVersionCheckRequest("", versioncheck.BuildNumber, "", "", "version", nil)
if err := v.CheckLatestVersion(ks.Context(), versionCheckRequest); err != nil {
return err
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(),
fmt.Fprintf(cmd.OutOrStdout(),
"Your current version is: %s\n",
version,
)
_, _ = fmt.Fprintf(cmd.OutOrStdout(),
"Build commit: %s\n",
commit,
)
_, _ = fmt.Fprintf(cmd.OutOrStdout(),
"Build date: %s\n",
date,
versionCheckRequest.ClientVersion,
)
return nil
},
}
return versionCmd
}
}

View File

@@ -20,13 +20,13 @@ func TestGetVersionCmd(t *testing.T) {
}{
{
name: "Undefined Build Number",
buildNumber: "unknown",
want: "Your current version is: unknown\nBuild commit: \nBuild date: \n",
buildNumber: "",
want: "Your current version is: unknown\n",
},
{
name: "Defined Build Number: v3.0.1",
buildNumber: "v3.0.1",
want: "Your current version is: v3.0.1\nBuild commit: \nBuild date: \n",
want: "Your current version is: v3.0.1\n",
},
}
for _, tt := range tests {
@@ -34,7 +34,7 @@ func TestGetVersionCmd(t *testing.T) {
versioncheck.BuildNumber = tt.buildNumber
ks := core.NewKubescape(context.TODO())
if cmd := GetVersionCmd(ks, tt.buildNumber, "", ""); cmd != nil {
if cmd := GetVersionCmd(ks); cmd != nil {
buf := bytes.NewBufferString("")
cmd.SetOut(buf)
cmd.Execute()
@@ -46,4 +46,4 @@ func TestGetVersionCmd(t *testing.T) {
}
})
}
}
}

View File

@@ -1,248 +1,14 @@
# Kubescape Core Package
The `core` package provides the main Kubescape scanning engine as a Go library, allowing you to integrate Kubescape security scanning directly into your applications.
## Table of Contents
- [Installation](#installation)
- [Quick Start](#quick-start)
- [API Reference](#api-reference)
- [Examples](#examples)
- [Configuration Options](#configuration-options)
---
## Installation
```bash
go get github.com/kubescape/kubescape/v3/core
```
---
## Quick Start
# Kubescape core package
```go
package main
import (
"context"
"fmt"
"log"
// initialize kubescape
ks := core.NewKubescape()
"github.com/kubescape/kubescape/v3/core"
"github.com/kubescape/kubescape/v3/core/cautils"
)
// scan cluster
results, err := ks.Scan(&cautils.ScanInfo{})
func main() {
ctx := context.Background()
// convert scan results to json
jsonRes, err := results.ToJson()
// Initialize Kubescape
ks := core.NewKubescape(ctx)
// Configure scan
scanInfo := &cautils.ScanInfo{
// Scan the current cluster
ScanAll: true,
}
// Run scan
results, err := ks.Scan(scanInfo)
if err != nil {
log.Fatalf("Scan failed: %v", err)
}
// Convert results to JSON
jsonRes, err := results.ToJson()
if err != nil {
log.Fatalf("Failed to convert results: %v", err)
}
fmt.Println(string(jsonRes))
}
```
---
## API Reference
### Creating a Kubescape Instance
```go
// Create with context
ks := core.NewKubescape(ctx)
```
### Scanning
```go
// Scan with configuration
results, err := ks.Scan(scanInfo)
```
### Listing Frameworks and Controls
```go
// List available policies
err := ks.List(listPolicies)
```
### Downloading Artifacts
```go
// Download for offline use
err := ks.Download(downloadInfo)
```
### Image Scanning
```go
// Scan container image
exceedsSeverity, err := ks.ScanImage(imgScanInfo, scanInfo)
```
### Fixing Misconfigurations
```go
// Apply fixes to manifests
err := ks.Fix(fixInfo)
```
---
## Examples
### Scan a Specific Framework
```go
scanInfo := &cautils.ScanInfo{}
scanInfo.SetPolicyIdentifiers([]string{"nsa"}, "framework")
results, err := ks.Scan(scanInfo)
```
### Scan Specific Namespaces
```go
scanInfo := &cautils.ScanInfo{
IncludeNamespaces: "production,staging",
}
results, err := ks.Scan(scanInfo)
```
### Scan Local YAML Files
```go
scanInfo := &cautils.ScanInfo{
InputPatterns: []string{"/path/to/manifests"},
}
scanInfo.SetScanType(cautils.ScanTypeRepo)
results, err := ks.Scan(scanInfo)
```
### Export Results to Different Formats
```go
results, _ := ks.Scan(scanInfo)
// JSON
jsonData, _ := results.ToJson()
// Get summary
summary := results.GetData().Report.SummaryDetails
fmt.Printf("Compliance Score: %.2f%%\n", summary.ComplianceScore)
```
### Scan with Compliance Threshold
```go
scanInfo := &cautils.ScanInfo{
ComplianceThreshold: 80.0, // Fail if below 80%
}
results, err := ks.Scan(scanInfo)
if err != nil {
// Handle scan failure
}
// Check if threshold was exceeded
if results.GetData().Report.SummaryDetails.ComplianceScore < scanInfo.ComplianceThreshold {
log.Fatal("Compliance score below threshold")
}
```
---
## Configuration Options
### ScanInfo Fields
| Field | Type | Description |
|-------|------|-------------|
| `AccountID` | string | Kubescape SaaS account ID |
| `AccessKey` | string | Kubescape SaaS access key |
| `InputPatterns` | []string | Paths to scan (files, directories, URLs) |
| `ExcludedNamespaces` | string | Comma-separated namespaces to exclude |
| `IncludeNamespaces` | string | Comma-separated namespaces to include |
| `Format` | string | Output format (json, junit, sarif, etc.) |
| `Output` | string | Output file path |
| `VerboseMode` | bool | Show all resources in output |
| `FailThreshold` | float32 | Fail threshold percentage |
| `ComplianceThreshold` | float32 | Compliance threshold percentage |
| `UseExceptions` | string | Path to exceptions file |
| `UseArtifactsFrom` | string | Path to offline artifacts |
| `Submit` | bool | Submit results to SaaS |
| `Local` | bool | Keep results local (don't submit) |
---
## Error Handling
```go
results, err := ks.Scan(scanInfo)
if err != nil {
switch {
case errors.Is(err, context.DeadlineExceeded):
log.Fatal("Scan timed out")
case errors.Is(err, context.Canceled):
log.Fatal("Scan was canceled")
default:
log.Fatalf("Scan error: %v", err)
}
}
```
---
## Thread Safety
The Kubescape instance is safe for concurrent use. You can run multiple scans in parallel:
```go
var wg sync.WaitGroup
for _, ns := range namespaces {
wg.Add(1)
go func(namespace string) {
defer wg.Done()
scanInfo := &cautils.ScanInfo{
IncludeNamespaces: namespace,
}
results, _ := ks.Scan(scanInfo)
// Process results...
}(ns)
}
wg.Wait()
```
---
## Related Documentation
- [CLI Reference](../docs/cli-reference.md)
- [Getting Started Guide](../docs/getting-started.md)
- [Architecture](../docs/architecture.md)
```

21
core/cautils/buildinfo.go Normal file
View File

@@ -0,0 +1,21 @@
package cautils
import (
"os"
"github.com/kubescape/backend/pkg/versioncheck"
)
var BuildNumber string
var Client string
func init() {
if BuildNumber != "" {
versioncheck.BuildNumber = BuildNumber
} else {
versioncheck.BuildNumber = os.Getenv("RELEASE")
}
if Client != "" {
versioncheck.Client = Client
}
}

View File

@@ -24,7 +24,8 @@ const (
configFileName string = "config"
kubescapeNamespace string = "kubescape"
kubescapeConfigMapName string = "kubescape-config" // deprecated - for backward compatibility
kubescapeConfigMapName string = "kubescape-config" // deprecated - for backward compatibility
kubescapeCloudConfigMapName string = "ks-cloud-config" // deprecated - for backward compatibility
cloudConfigMapLabelSelector string = "kubescape.io/infra=config"
credsLabelSelectors string = "kubescape.io/infra=credentials" //nolint:gosec
@@ -206,8 +207,6 @@ func NewClusterConfig(k8s *k8sinterface.KubernetesApi, accountID, accessKey, clu
loadConfigFromFile(c.configObj)
}
loadUrlsFromFile(c.configObj)
// second, load urls from config map
c.updateConfigEmptyFieldsFromKubescapeConfigMap()
@@ -271,12 +270,15 @@ func (c *ClusterConfig) updateConfigEmptyFieldsFromKubescapeConfigMap() error {
return err
}
var ksConfigMap *corev1.ConfigMap
var urlsConfigMap *corev1.ConfigMap
if len(configMaps.Items) == 0 {
// try to find configmaps by name (for backward compatibility)
ksConfigMap, _ = c.k8s.KubernetesClient.CoreV1().ConfigMaps(c.configMapNamespace).Get(context.Background(), kubescapeConfigMapName, metav1.GetOptions{})
urlsConfigMap, _ = c.k8s.KubernetesClient.CoreV1().ConfigMaps(c.configMapNamespace).Get(context.Background(), kubescapeCloudConfigMapName, metav1.GetOptions{})
} else {
// use the first configmap with the label
ksConfigMap = &configMaps.Items[0]
urlsConfigMap = &configMaps.Items[0]
}
if ksConfigMap != nil {
@@ -289,6 +291,30 @@ func (c *ClusterConfig) updateConfigEmptyFieldsFromKubescapeConfigMap() error {
}
}
if urlsConfigMap != nil {
if jsonConf, ok := urlsConfigMap.Data["services"]; ok {
services, err := servicediscovery.GetServices(
servicediscoveryv2.NewServiceDiscoveryStreamV2([]byte(jsonConf)),
)
if err != nil {
// try to parse as v1
services, err = servicediscovery.GetServices(
servicediscoveryv1.NewServiceDiscoveryStreamV1([]byte(jsonConf)),
)
if err != nil {
return err
}
}
if services.GetApiServerUrl() != "" {
c.configObj.CloudAPIURL = services.GetApiServerUrl()
}
if services.GetReportReceiverHttpUrl() != "" {
c.configObj.CloudReportURL = services.GetReportReceiverHttpUrl()
}
}
}
return err
}
@@ -371,7 +397,7 @@ func (c *ClusterConfig) updateConfigData(configMap *corev1.ConfigMap) {
func loadConfigFromFile(configObj *ConfigObj) error {
dat, err := os.ReadFile(ConfigFileFullPath())
if err != nil {
return nil // no config file
return err
}
return readConfig(dat, configObj)
}
@@ -387,32 +413,6 @@ func readConfig(dat []byte, configObj *ConfigObj) error {
return nil
}
func loadUrlsFromFile(obj *ConfigObj) error {
dat, err := os.ReadFile("/etc/config/services.json")
if err != nil {
return nil // no config file
}
services, err := servicediscovery.GetServices(
servicediscoveryv2.NewServiceDiscoveryStreamV2(dat),
)
if err != nil {
// try to parse as v1
services, err = servicediscovery.GetServices(
servicediscoveryv1.NewServiceDiscoveryStreamV1(dat),
)
if err != nil {
return err
}
}
if services.GetApiServerUrl() != "" {
obj.CloudAPIURL = services.GetApiServerUrl()
}
if services.GetReportReceiverHttpUrl() != "" {
obj.CloudReportURL = services.GetReportReceiverHttpUrl()
}
return nil
}
func DeleteConfigFile() error {
return os.Remove(ConfigFileFullPath())
}
@@ -521,3 +521,9 @@ func GetTenantConfig(accountID, accessKey, clusterName, customClusterName string
}
// firstNonEmpty returns the first non-empty string
func firstNonEmpty(s1, s2 string) string {
if s1 != "" {
return s1
}
return s2
}

View File

@@ -4,10 +4,7 @@ import (
"context"
"sort"
"github.com/anchore/grype/grype/match"
"github.com/anchore/grype/grype/pkg"
"github.com/anchore/grype/grype/vulnerability"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/grype/grype/presenter/models"
"github.com/armosec/armoapi-go/armotypes"
"github.com/kubescape/k8s-interface/workloadinterface"
"github.com/kubescape/opa-utils/reporthandling"
@@ -23,14 +20,8 @@ type K8SResources map[string][]string
type ExternalResources map[string][]string
type ImageScanData struct {
Context pkg.Context
IgnoredMatches []match.IgnoredMatch
Image string
Matches match.Matches
Packages []pkg.Package
RemainingMatches *match.Matches
SBOM *sbom.SBOM
VulnerabilityProvider vulnerability.Provider
PresenterConfig *models.PresenterConfig
Image string
}
type ScanTypes string
@@ -69,42 +60,27 @@ type OPASessionObj struct {
TopWorkloadsByScore []reporthandling.IResource
TemplateMapping map[string]MappingNodes // Map chart obj to template (only for rendering from path)
TriggeredByCLI bool
LabelsToCopy []string // Labels to copy from workloads to scan reports
}
func NewOPASessionObj(ctx context.Context, frameworks []reporthandling.Framework, k8sResources K8SResources, scanInfo *ScanInfo) *OPASessionObj {
clusterSize := estimateClusterSize(k8sResources)
if clusterSize < 100 {
clusterSize = 100
}
return &OPASessionObj{
Report: &reporthandlingv2.PostureReport{},
Policies: frameworks,
K8SResources: k8sResources,
AllResources: make(map[string]workloadinterface.IMetadata, clusterSize),
ResourcesResult: make(map[string]resourcesresults.Result, clusterSize),
ResourcesPrioritized: make(map[string]prioritization.PrioritizedResource, clusterSize/10),
InfoMap: make(map[string]apis.StatusInfo, clusterSize/10),
ResourceToControlsMap: make(map[string][]string, clusterSize/2),
ResourceSource: make(map[string]reporthandling.Source, clusterSize),
AllResources: make(map[string]workloadinterface.IMetadata),
ResourcesResult: make(map[string]resourcesresults.Result),
ResourcesPrioritized: make(map[string]prioritization.PrioritizedResource),
InfoMap: make(map[string]apis.StatusInfo),
ResourceToControlsMap: make(map[string][]string),
ResourceSource: make(map[string]reporthandling.Source),
SessionID: scanInfo.ScanID,
Metadata: scanInfoToScanMetadata(ctx, scanInfo),
OmitRawResources: scanInfo.OmitRawResources,
TriggeredByCLI: scanInfo.TriggeredByCLI,
TemplateMapping: make(map[string]MappingNodes, clusterSize/10),
LabelsToCopy: scanInfo.LabelsToCopy,
TemplateMapping: make(map[string]MappingNodes),
}
}
func estimateClusterSize(k8sResources K8SResources) int {
total := 0
for _, resourceIDs := range k8sResources {
total += len(resourceIDs)
}
return total
}
// SetTopWorkloads sets the top workloads by score
func (sessionObj *OPASessionObj) SetTopWorkloads() {
count := 0

View File

@@ -76,18 +76,14 @@ func ShouldSkipRule(control reporthandling.Control, rule reporthandling.PolicyRu
// In local build (BuildNumber = ""):
// returns true only if rule doesn't have the "until" attribute
func isRuleKubescapeVersionCompatible(attributes map[string]interface{}, version string) bool {
normalizedVersion := version
if version != "" && !semver.IsValid(version) {
normalizedVersion = "v" + version
}
if from, ok := attributes["useFromKubescapeVersion"]; ok && from != nil {
switch sfrom := from.(type) {
case string:
if normalizedVersion != "" && semver.IsValid(normalizedVersion) && semver.Compare(normalizedVersion, sfrom) == -1 {
if version != "" && semver.Compare(version, sfrom) == -1 {
return false
}
default:
// Handle case where useFromKubescapeVersion is not a string
return false
}
}
@@ -95,10 +91,11 @@ func isRuleKubescapeVersionCompatible(attributes map[string]interface{}, version
if until, ok := attributes["useUntilKubescapeVersion"]; ok && until != nil {
switch suntil := until.(type) {
case string:
if normalizedVersion == "" || (semver.IsValid(normalizedVersion) && semver.Compare(normalizedVersion, suntil) >= 0) {
if version == "" || semver.Compare(version, suntil) >= 0 {
return false
}
default:
// Handle case where useUntilKubescapeVersion is not a string
return false
}
}

View File

@@ -322,7 +322,7 @@ func glob(root, pattern string, onlyDirectories bool) ([]string, error) {
return nil
}
fileFormat := getFileFormat(path)
if fileFormat != JSON_FILE_FORMAT && fileFormat != YAML_FILE_FORMAT {
if !(fileFormat == JSON_FILE_FORMAT || fileFormat == YAML_FILE_FORMAT) {
return nil
}
if matched, err := filepath.Match(pattern, filepath.Base(path)); err != nil {

View File

@@ -1,7 +1,7 @@
package getter
import (
"io"
"io/ioutil"
"net/http"
"os"
"path/filepath"
@@ -102,7 +102,7 @@ func TestHttpRespToString_NilResponse(t *testing.T) {
func TestHttpRespToString_ValidResponse(t *testing.T) {
resp := &http.Response{
Body: io.NopCloser(strings.NewReader("test response")),
Body: ioutil.NopCloser(strings.NewReader("test response")),
Status: "200 OK",
StatusCode: 200,
}
@@ -114,7 +114,7 @@ func TestHttpRespToString_ValidResponse(t *testing.T) {
// Returns an error with status and reason when unable to read response body.
func TestHttpRespToString_ReadError(t *testing.T) {
resp := &http.Response{
Body: io.NopCloser(strings.NewReader("test response")),
Body: ioutil.NopCloser(strings.NewReader("test response")),
}
resp.Body.Close()
result, err := httpRespToString(resp)
@@ -125,7 +125,7 @@ func TestHttpRespToString_ReadError(t *testing.T) {
// Returns an error with status and reason when unable to read response body.
func TestHttpRespToString_ErrorCodeLessThan200(t *testing.T) {
resp := &http.Response{
Body: io.NopCloser(strings.NewReader("test response")),
Body: ioutil.NopCloser(strings.NewReader("test response")),
StatusCode: 100,
}
resp.Body.Close()

View File

@@ -5,6 +5,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
@@ -24,6 +25,10 @@ const (
var (
globalMx sync.Mutex // a mutex to avoid data races on package globals while testing
testOptions = []v1.KSCloudOption{
v1.WithTrace(os.Getenv("DEBUG_TEST") != ""),
}
)
func TestGlobalKSCloudAPIConnector(t *testing.T) {
@@ -108,6 +113,8 @@ func mockAPIServer(t testing.TB) *testServer {
defer func() { _ = r.Body.Close() }()
_, _ = io.Copy(w, r.Body)
return
})
return server

View File

@@ -226,7 +226,7 @@ func (lp *LoadPolicy) GetControlsInputs(_ /* clusterName */ string) (map[string]
buf, err := os.ReadFile(filePath)
if err != nil {
formattedError := fmt.Errorf(
`error opening %s file, "controls-config" will be downloaded from ARMO management portal`,
`Error opening %s file, "controls-config" will be downloaded from ARMO management portal`,
fileName,
)
@@ -236,7 +236,7 @@ func (lp *LoadPolicy) GetControlsInputs(_ /* clusterName */ string) (map[string]
controlInputs := make(map[string][]string, 100) // from armotypes.Settings.PostureControlInputs
if err = json.Unmarshal(buf, &controlInputs); err != nil {
formattedError := fmt.Errorf(
`error reading %s file, %v, "controls-config" will be downloaded from ARMO management portal`,
`Error reading %s file, %v, "controls-config" will be downloaded from ARMO management portal`,
fileName, err,
)

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +1,6 @@
package cautils
import (
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
@@ -15,12 +12,7 @@ import (
helmchart "helm.sh/helm/v3/pkg/chart"
helmloader "helm.sh/helm/v3/pkg/chart/loader"
helmchartutil "helm.sh/helm/v3/pkg/chartutil"
"helm.sh/helm/v3/pkg/cli"
helmdownloader "helm.sh/helm/v3/pkg/downloader"
helmengine "helm.sh/helm/v3/pkg/engine"
helmgetter "helm.sh/helm/v3/pkg/getter"
helmregistry "helm.sh/helm/v3/pkg/registry"
"k8s.io/client-go/util/homedir"
)
type HelmChart struct {
@@ -32,51 +24,7 @@ func IsHelmDirectory(path string) (bool, error) {
return helmchartutil.IsChartDir(path)
}
// newRegistryClient creates a Helm registry client for chart authentication
func newRegistryClient(certFile, keyFile, caFile string, insecureSkipTLS, plainHTTP bool, username, password string) (*helmregistry.Client, error) {
// Basic client options with debug disabled
opts := []helmregistry.ClientOption{
helmregistry.ClientOptDebug(false),
helmregistry.ClientOptWriter(io.Discard),
}
// Add TLS certificates if provided
if certFile != "" && keyFile != "" {
opts = append(opts, helmregistry.ClientOptCredentialsFile(certFile))
}
// Add CA certificate if provided
if caFile != "" {
opts = append(opts, helmregistry.ClientOptCredentialsFile(caFile))
}
// Enable plain HTTP if needed
if insecureSkipTLS {
opts = append(opts, helmregistry.ClientOptPlainHTTP())
}
registryClient, err := helmregistry.NewClient(opts...)
if err != nil {
return nil, err
}
return registryClient, nil
}
// defaultKeyring returns the default GPG keyring path for chart verification
func defaultKeyring() string {
if v, ok := os.LookupEnv("GNUPGHOME"); ok {
return filepath.Join(v, "pubring.gpg")
}
return filepath.Join(homedir.HomeDir(), ".gnupg", "pubring.gpg")
}
func NewHelmChart(path string) (*HelmChart, error) {
// Build chart dependencies before loading if Chart.lock exists
if err := buildDependencies(path); err != nil {
logger.L().Warning("Failed to build chart dependencies", helpers.String("path", path), helpers.Error(err))
}
chart, err := helmloader.Load(path)
if err != nil {
return nil, err
@@ -88,35 +36,6 @@ func NewHelmChart(path string) (*HelmChart, error) {
}, nil
}
// buildDependencies builds chart dependencies using the downloader manager
func buildDependencies(chartPath string) error {
// Create registry client for authentication
registryClient, err := newRegistryClient("", "", "", false, false, "", "")
if err != nil {
return fmt.Errorf("failed to create registry client: %w", err)
}
// Create downloader manager with required configuration
settings := cli.New()
manager := &helmdownloader.Manager{
Out: io.Discard, // Suppress output during scanning
ChartPath: chartPath,
Keyring: defaultKeyring(),
SkipUpdate: false, // Allow updates to get latest dependencies
Getters: helmgetter.All(settings),
RegistryClient: registryClient,
Debug: false,
}
// Build dependencies from Chart.lock file
err = manager.Build()
if e, ok := err.(helmdownloader.ErrRepoNotFound); ok {
return fmt.Errorf("%s. Please add missing repos via 'helm repo add'", e.Error())
}
return err
}
func (hc *HelmChart) GetName() string {
return hc.chart.Name()
}

View File

@@ -9,7 +9,6 @@ import (
"github.com/kubescape/k8s-interface/workloadinterface"
"github.com/kubescape/opa-utils/objectsenvelopes/localworkload"
"sigs.k8s.io/kustomize/api/krusty"
"sigs.k8s.io/kustomize/api/types"
"sigs.k8s.io/kustomize/kyaml/filesys"
)
@@ -76,11 +75,7 @@ func getKustomizeDirectoryName(path string) string {
func (kd *KustomizeDirectory) GetWorkloads(kustomizeDirectoryPath string) (map[string][]workloadinterface.IMetadata, []error) {
fSys := filesys.MakeFsOnDisk()
// Use LoadRestrictionsNone to allow loading resources from outside the kustomize directory.
// This is necessary for overlays that reference base configurations in parent directories.
opts := krusty.MakeDefaultOptions()
opts.LoadRestrictions = types.LoadRestrictionsNone
kustomizer := krusty.MakeKustomizer(opts)
kustomizer := krusty.MakeKustomizer(krusty.MakeDefaultOptions())
resmap, err := kustomizer.Run(fSys, kustomizeDirectoryPath)
if err != nil {

View File

@@ -4,8 +4,6 @@ import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetKustomizeDirectoryName(t *testing.T) {
@@ -54,7 +52,7 @@ func TestGetKustomizeDirectoryName(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
tempFile := filepath.Join(tt.args.path, "kustomization.yaml")
if tt.createKustomization {
_ = os.WriteFile(tempFile, []byte(""), 0600)
_ = os.WriteFile(tempFile, []byte(""), 0644)
}
if got := getKustomizeDirectoryName(tt.args.path); got != tt.want {
t.Errorf("GetKustomizeDirectoryName() = %v, want %v", got, tt.want)
@@ -63,83 +61,3 @@ func TestGetKustomizeDirectoryName(t *testing.T) {
})
}
}
func kustomizeTestdataPath() string {
o, _ := os.Getwd()
return filepath.Join(o, "testdata", "kustomize")
}
// TestKustomizeOverlayWithBase tests that kustomize overlays can properly load
// resources from base directories. This is the main fix for issue #1617.
func TestKustomizeOverlayWithBase(t *testing.T) {
overlayPath := filepath.Join(kustomizeTestdataPath(), "overlays", "prod")
// Verify it's detected as a kustomize directory
assert.True(t, isKustomizeDirectory(overlayPath), "overlay should be detected as kustomize directory")
// Create kustomize directory and get workloads
kd := NewKustomizeDirectory(overlayPath)
workloads, errs := kd.GetWorkloads(overlayPath)
// Should not have errors - this was failing before the fix because
// overlays couldn't load resources from parent base directories
assert.Empty(t, errs, "should not have errors loading overlay with base reference")
// Should have workloads from the rendered overlay
assert.NotEmpty(t, workloads, "should have workloads from rendered kustomize overlay")
// The overlay should have produced exactly one deployment with the merged configuration
var deploymentFound bool
for _, wls := range workloads {
for _, wl := range wls {
if wl.GetKind() == "Deployment" && wl.GetName() == "test-app" {
deploymentFound = true
// Verify the deployment has the resource limits from the base
obj := wl.GetObject()
spec, ok := obj["spec"].(map[string]interface{})
assert.True(t, ok, "deployment should have spec")
template, ok := spec["template"].(map[string]interface{})
assert.True(t, ok, "deployment should have template")
templateSpec, ok := template["spec"].(map[string]interface{})
assert.True(t, ok, "template should have spec")
containers, ok := templateSpec["containers"].([]interface{})
assert.True(t, ok, "template spec should have containers")
assert.NotEmpty(t, containers, "should have at least one container")
container, ok := containers[0].(map[string]interface{})
assert.True(t, ok, "container should be a map")
resources, ok := container["resources"].(map[string]interface{})
assert.True(t, ok, "container should have resources (from base)")
limits, ok := resources["limits"].(map[string]interface{})
assert.True(t, ok, "resources should have limits")
assert.Equal(t, "500m", limits["cpu"], "cpu limit should be from base")
assert.Equal(t, "256Mi", limits["memory"], "memory limit should be from base")
// Verify overlay modifications were applied
replicas, ok := spec["replicas"].(int)
assert.True(t, ok, "replicas should be an int")
assert.Equal(t, 3, replicas, "replicas should be modified by overlay")
}
}
}
assert.True(t, deploymentFound, "deployment should be found in rendered output")
}
// TestKustomizeBaseDirectory tests that base directories work on their own
func TestKustomizeBaseDirectory(t *testing.T) {
basePath := filepath.Join(kustomizeTestdataPath(), "base")
assert.True(t, isKustomizeDirectory(basePath), "base should be detected as kustomize directory")
kd := NewKustomizeDirectory(basePath)
workloads, errs := kd.GetWorkloads(basePath)
assert.Empty(t, errs, "should not have errors loading base directory")
assert.NotEmpty(t, workloads, "should have workloads from base directory")
}

View File

@@ -81,7 +81,7 @@ func Test_GetRequestPayload(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := tc.GetRequestPayload()
result := tc.OperatorScanInfo.GetRequestPayload()
assert.Equal(t, tc.result, result)
})
}
@@ -136,8 +136,8 @@ func Test_ValidatePayload(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
payload := tc.GetRequestPayload()
result := tc.ValidatePayload(payload)
payload := tc.OperatorScanInfo.GetRequestPayload()
result := tc.OperatorScanInfo.ValidatePayload(payload)
assert.Equal(t, tc.result, result)
})
}

View File

@@ -170,6 +170,7 @@ func getInfoFromOne(output string, lastNumber int, isMapType bool) (value string
if isMapType {
lineNumber = lineNumber - 1
}
lastNumber = lineNumber
// save to structure
} else {
lineNumber = lastNumber

View File

@@ -78,7 +78,7 @@ func (p *portForward) StopPortForwarder() {
func (p *portForward) StartPortForwarder() error {
go func() {
p.ForwardPorts()
p.PortForwarder.ForwardPorts()
}()
p.waitForPortForwardReadiness()

View File

@@ -64,7 +64,7 @@ func Test_CreatePortForwarder(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
k8sClient := k8sinterface.KubernetesApi{
KubernetesClient: fake.NewClientset(),
KubernetesClient: fake.NewSimpleClientset(),
K8SConfig: &rest.Config{
Host: "any",
},
@@ -105,7 +105,7 @@ func Test_GetPortForwardLocalhost(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
k8sClient := k8sinterface.KubernetesApi{
KubernetesClient: fake.NewClientset(),
KubernetesClient: fake.NewSimpleClientset(),
K8SConfig: &rest.Config{
Host: "any",
},

View File

@@ -137,10 +137,8 @@ type ScanInfo struct {
TriggeredByCLI bool // indicates whether the scan was triggered by the CLI
ScanType ScanTypes
ScanImages bool
UseDefaultMatchers bool
ChartPath string
FilePath string
LabelsToCopy []string // Labels to copy from workloads to scan reports
scanningContext *ScanningContext
cleanups []func()
}
@@ -322,9 +320,6 @@ func (scanInfo *ScanInfo) getScanningContext(input string) ScanningContext {
return ContextCluster
}
// Check if input is a URL (http:// or https://)
isURL := isHTTPURL(input)
// git url
if _, err := giturl.NewGitURL(input); err == nil {
if repo, err := CloneGitRepo(&input); err == nil {
@@ -335,18 +330,6 @@ func (scanInfo *ScanInfo) getScanningContext(input string) ScanningContext {
return ContextGitRemote
}
}
// If giturl.NewGitURL succeeded but cloning failed, the input is a git URL
// that couldn't be cloned. Don't treat it as a local path.
// The clone error was already logged by CloneGitRepo.
// Return ContextDir to prevent the URL from being joined with the current directory
// and to trigger a "no files found" error with the actual URL (not a mangled path).
return ContextDir
}
// If it looks like a URL but wasn't recognized as a git URL, still don't treat it as a local path
if isURL {
logger.L().Error("URL provided but not recognized as a valid git repository. Ensure the URL is correct and accessible", helpers.String("url", input))
return ContextDir
}
if !filepath.IsAbs(input) { // parse path
@@ -472,8 +455,3 @@ func getAbsPath(p string) string {
}
return p
}
// isHTTPURL checks if the input string is an HTTP or HTTPS URL
func isHTTPURL(input string) bool {
return strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://")
}

View File

@@ -88,16 +88,6 @@ func TestGetScanningContext(t *testing.T) {
input: os.TempDir(),
want: ContextDir,
},
{
name: "self-hosted GitLab URL that can't be cloned",
input: "https://gitlab.private-domain.com/my-org/my-repo.git",
want: ContextDir, // Should return ContextDir when clone fails, not try to treat as local path
},
{
name: "http URL that can't be cloned",
input: "http://gitlab.example.com/org/repo",
want: ContextDir, // Should return ContextDir when clone fails, not try to treat as local path
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View File

@@ -1,28 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: test-app
labels:
app: test-app
spec:
replicas: 1
selector:
matchLabels:
app: test-app
template:
metadata:
labels:
app: test-app
spec:
containers:
- name: test-container
image: nginx:1.19
resources:
limits:
cpu: "500m"
memory: "256Mi"
requests:
cpu: "100m"
memory: "128Mi"
ports:
- containerPort: 80

View File

@@ -1,5 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml

View File

@@ -1,13 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
images:
- name: nginx
newTag: "1.21"
replicas:
- name: test-app
count: 3

View File

@@ -36,7 +36,7 @@ func getOperatorPod(k8sClient *k8sinterface.KubernetesApi, ns string) (*v1.Pod,
return nil, err
}
if len(pods.Items) != 1 {
return nil, errors.New("could not find the Kubescape Operator chart, please validate that the Kubescape Operator helm chart is installed and running -> https://github.com/kubescape/helm-charts")
return nil, errors.New("Could not find the Kubescape Operator chart, please validate that the Kubescape Operator helm chart is installed and running -> https://github.com/kubescape/helm-charts")
}
return &pods.Items[0], nil
@@ -90,8 +90,8 @@ func (a *OperatorAdapter) httpPostOperatorScanRequest(body apis.Commands) (strin
}
func (a *OperatorAdapter) OperatorScan() (string, error) {
payload := a.GetRequestPayload()
if err := a.ValidatePayload(payload); err != nil {
payload := a.OperatorScanInfo.GetRequestPayload()
if err := a.OperatorScanInfo.ValidatePayload(payload); err != nil {
return "", err
}
res, err := a.httpPostOperatorScanRequest(*payload)

View File

@@ -23,13 +23,13 @@ func Test_getOperatorPod(t *testing.T) {
name: "test error no operator exist",
createOperatorPod: false,
createAnotherOperatorPodWithSameLabel: false,
expectedError: fmt.Errorf("could not find the Kubescape Operator chart, please validate that the Kubescape Operator helm chart is installed and running -> https://github.com/kubescape/helm-charts"),
expectedError: fmt.Errorf("Could not find the Kubescape Operator chart, please validate that the Kubescape Operator helm chart is installed and running -> https://github.com/kubescape/helm-charts"),
},
{
name: "test error several operators exist",
createOperatorPod: true,
createAnotherOperatorPodWithSameLabel: true,
expectedError: fmt.Errorf("could not find the Kubescape Operator chart, please validate that the Kubescape Operator helm chart is installed and running -> https://github.com/kubescape/helm-charts"),
expectedError: fmt.Errorf("Could not find the Kubescape Operator chart, please validate that the Kubescape Operator helm chart is installed and running -> https://github.com/kubescape/helm-charts"),
},
{
name: "test no error",
@@ -42,7 +42,7 @@ func Test_getOperatorPod(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
k8sClient := k8sinterface.KubernetesApi{
KubernetesClient: fake.NewClientset(),
KubernetesClient: fake.NewSimpleClientset(),
Context: context.TODO(),
}

View File

@@ -48,7 +48,7 @@ func (ks *Kubescape) Fix(fixInfo *metav1.FixInfo) error {
for _, err := range errors {
logger.L().Ctx(ks.Context()).Warning(err.Error())
}
return fmt.Errorf("failed to fix some resources, check the logs for more details")
return fmt.Errorf("Failed to fix some resources, check the logs for more details")
}
return nil
@@ -64,10 +64,9 @@ func userConfirmed() bool {
}
input = strings.ToLower(input)
switch input {
case "y", "yes":
if input == "y" || input == "yes" {
return true
case "n", "no":
} else if input == "n" || input == "no" {
return false
}
}

View File

@@ -33,11 +33,10 @@ func TestUserConfirmed(t *testing.T) {
for _, tt := range tests {
t.Run(string(tt.input), func(t *testing.T) {
originalStdin := os.Stdin
r, w, _ := os.Pipe()
os.Stdin = r
defer func() {
os.Stdin = originalStdin
os.Stdin = os.Stdin
}()
go func() {

View File

@@ -7,6 +7,7 @@ import (
"regexp"
"strings"
"github.com/anchore/grype/grype/presenter/models"
"github.com/kubescape/go-logger"
"github.com/kubescape/kubescape/v3/core/cautils"
ksmetav1 "github.com/kubescape/kubescape/v3/core/meta/datastructures/v1"
@@ -110,9 +111,7 @@ func regexStringMatch(pattern, target string) bool {
// exception policy.
func isTargetImage(targets []Target, attributes Attributes) bool {
for _, target := range targets {
if regexStringMatch(target.Attributes.Registry, attributes.Registry) && regexStringMatch(target.Attributes.Organization, attributes.Organization) && regexStringMatch(target.Attributes.ImageName, attributes.ImageName) && regexStringMatch(target.Attributes.ImageTag, attributes.ImageTag) {
return true
}
return regexStringMatch(target.Attributes.Registry, attributes.Registry) && regexStringMatch(target.Attributes.Organization, attributes.Organization) && regexStringMatch(target.Attributes.ImageName, attributes.ImageName) && regexStringMatch(target.Attributes.ImageTag, attributes.ImageTag)
}
return false
@@ -162,16 +161,11 @@ func getUniqueVulnerabilitiesAndSeverities(policies []VulnerabilitiesIgnorePolic
return uniqueVulnsList, uniqueSeversList
}
func (ks *Kubescape) ScanImage(imgScanInfo *ksmetav1.ImageScanInfo, scanInfo *cautils.ScanInfo) (bool, error) {
func (ks *Kubescape) ScanImage(imgScanInfo *ksmetav1.ImageScanInfo, scanInfo *cautils.ScanInfo) (*models.PresenterConfig, error) {
logger.L().Start(fmt.Sprintf("Scanning image %s...", imgScanInfo.Image))
distCfg, installCfg, _ := imagescan.NewDefaultDBConfig()
svc, err := imagescan.NewScanServiceWithMatchers(distCfg, installCfg, imgScanInfo.UseDefaultMatchers)
if err != nil {
logger.L().StopError(fmt.Sprintf("Failed to initialize image scanner: %s", err))
return false, err
}
defer svc.Close()
dbCfg, _ := imagescan.NewDefaultDBConfig()
svc := imagescan.NewScanService(dbCfg)
creds := imagescan.RegistryCredentials{
Username: imgScanInfo.Username,
@@ -184,16 +178,16 @@ func (ks *Kubescape) ScanImage(imgScanInfo *ksmetav1.ImageScanInfo, scanInfo *ca
exceptionPolicies, err := GetImageExceptionsFromFile(imgScanInfo.Exceptions)
if err != nil {
logger.L().StopError(fmt.Sprintf("Failed to load exceptions from file: %s", imgScanInfo.Exceptions))
return false, err
return nil, err
}
vulnerabilityExceptions, severityExceptions = getUniqueVulnerabilitiesAndSeverities(exceptionPolicies, imgScanInfo.Image)
}
imageScanData, err := svc.Scan(ks.Context(), imgScanInfo.Image, creds, vulnerabilityExceptions, severityExceptions)
scanResults, err := svc.Scan(ks.Context(), imgScanInfo.Image, creds, vulnerabilityExceptions, severityExceptions)
if err != nil {
logger.L().StopError(fmt.Sprintf("Failed to scan image: %s", imgScanInfo.Image))
return false, err
return nil, err
}
logger.L().StopSuccess(fmt.Sprintf("Successfully scanned image: %s", imgScanInfo.Image))
@@ -206,7 +200,12 @@ func (ks *Kubescape) ScanImage(imgScanInfo *ksmetav1.ImageScanInfo, scanInfo *ca
resultsHandler := resultshandling.NewResultsHandler(nil, outputPrinters, uiPrinter)
resultsHandler.ImageScanData = []cautils.ImageScanData{*imageScanData}
resultsHandler.ImageScanData = []cautils.ImageScanData{
{
PresenterConfig: scanResults,
Image: imgScanInfo.Image,
},
}
return svc.ExceedsSeverityThreshold(imagescan.ParseSeverity(scanInfo.FailThresholdSeverity), imageScanData.Matches), resultsHandler.HandleResults(ks.Context(), scanInfo)
return scanResults, resultsHandler.HandleResults(ks.Context())
}

View File

@@ -241,33 +241,6 @@ func TestIsTargetImage(t *testing.T) {
},
expected: true,
},
{
targets: []Target{
{
Attributes: Attributes{
Registry: "quay.io",
Organization: "kubescape",
ImageName: "kubescape*",
ImageTag: "",
},
},
{
Attributes: Attributes{
Registry: "docker.io",
Organization: "library",
ImageName: "alpine",
ImageTag: "",
},
},
},
attributes: Attributes{
Registry: "docker.io",
Organization: "library",
ImageName: "alpine",
ImageTag: "latest",
},
expected: true,
},
}
for _, tt := range tests {

View File

@@ -82,7 +82,7 @@ func getReporter(ctx context.Context, tenantConfig cautils.ITenantConfig, report
}
func getResourceHandler(ctx context.Context, scanInfo *cautils.ScanInfo, tenantConfig cautils.ITenantConfig, k8s *k8sinterface.KubernetesApi, hostSensorHandler hostsensorutils.IHostSensor) resourcehandler.IResourceHandler {
_, span := otel.Tracer("").Start(ctx, "getResourceHandler")
ctx, span := otel.Tracer("").Start(ctx, "getResourceHandler")
defer span.End()
if len(scanInfo.InputPatterns) > 0 || k8s == nil {
@@ -90,11 +90,7 @@ func getResourceHandler(ctx context.Context, scanInfo *cautils.ScanInfo, tenantC
return resourcehandler.NewFileResourceHandler()
}
// Only initialize cloud connector if not in air-gapped mode
// This call initializes the global cloud API connector for later use
if !isAirGappedMode(scanInfo) {
_ = getter.GetKSCloudAPIConnector()
}
getter.GetKSCloudAPIConnector()
rbacObjects := getRBACHandler(tenantConfig, k8s, scanInfo.Submit)
return resourcehandler.NewK8sResourceHandler(k8s, hostSensorHandler, rbacObjects, tenantConfig.GetContextName())
}

View File

@@ -7,13 +7,14 @@ import (
"sort"
"strings"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/jedib0t/go-pretty/v6/text"
"github.com/jwalton/gchalk"
"github.com/kubescape/kubescape/v3/core/cautils"
metav1 "github.com/kubescape/kubescape/v3/core/meta/datastructures/v1"
"github.com/kubescape/kubescape/v3/core/pkg/resultshandling/printer"
v2 "github.com/kubescape/kubescape/v3/core/pkg/resultshandling/printer/v2"
"github.com/kubescape/kubescape/v3/core/pkg/resultshandling/printer/v2/prettyprinter/tableprinter/utils"
"github.com/maruel/natural"
"github.com/olekukonko/tablewriter"
)
var listFunc = map[string]func(context.Context, *metav1.ListPolicies) ([]string, error){
@@ -48,7 +49,7 @@ func (ks *Kubescape) List(listPolicies *metav1.ListPolicies) error {
if listFormatFunction, ok := listFormatFunc[listPolicies.Format]; ok {
listFormatFunction(ks.Context(), listPolicies.Target, policies)
} else {
return fmt.Errorf("invalid format \"%s\", supported formats: 'pretty-print'/'json' ", listPolicies.Format)
return fmt.Errorf("Invalid format \"%s\", Supported formats: 'pretty-print'/'json' ", listPolicies.Format)
}
return nil
@@ -99,19 +100,30 @@ func prettyPrintListFormat(ctx context.Context, targetPolicy string, policies []
return
}
policyTable := table.NewWriter()
policyTable.SetOutputMirror(printer.GetWriter(ctx, ""))
policyTable := tablewriter.NewWriter(printer.GetWriter(ctx, ""))
policyTable.SetAutoWrapText(true)
header := fmt.Sprintf("Supported %s", targetPolicy)
policyTable.AppendHeader(table.Row{header})
policyTable.Style().Options.SeparateHeader = true
policyTable.Style().Options.SeparateRows = true
policyTable.Style().Format.HeaderAlign = text.AlignLeft
policyTable.Style().Format.Header = text.FormatDefault
policyTable.Style().Format.RowAlign = text.AlignCenter
policyTable.Style().Box = table.StyleBoxRounded
policyTable.SetHeader([]string{header})
policyTable.SetHeaderLine(true)
policyTable.SetRowLine(true)
policyTable.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
policyTable.SetAutoFormatHeaders(false)
policyTable.SetAlignment(tablewriter.ALIGN_CENTER)
policyTable.SetUnicodeHVC(tablewriter.Regular, tablewriter.Regular, gchalk.Ansi256(238))
data := v2.Matrix{}
policyTable.AppendRows(generatePolicyRows(policies))
controlRows := generatePolicyRows(policies)
var headerColors []tablewriter.Colors
for range controlRows[0] {
headerColors = append(headerColors, tablewriter.Colors{tablewriter.Bold, tablewriter.FgHiYellowColor})
}
policyTable.SetHeaderColor(headerColors...)
data = append(data, controlRows...)
policyTable.AppendBulk(data)
policyTable.Render()
}
@@ -122,32 +134,40 @@ func jsonListFormat(_ context.Context, _ string, policies []string) {
}
func prettyPrintControls(ctx context.Context, policies []string) {
controlsTable := table.NewWriter()
controlsTable.SetOutputMirror(printer.GetWriter(ctx, ""))
controlsTable := tablewriter.NewWriter(printer.GetWriter(ctx, ""))
controlsTable.Style().Options.SeparateHeader = true
controlsTable.Style().Options.SeparateRows = true
controlsTable.Style().Format.HeaderAlign = text.AlignLeft
controlsTable.Style().Format.Header = text.FormatDefault
controlsTable.Style().Box = table.StyleBoxRounded
controlsTable.SetColumnConfigs([]table.ColumnConfig{{Number: 1, Align: text.AlignRight}})
controlsTable.SetAutoWrapText(false)
controlsTable.SetHeaderLine(true)
controlsTable.SetRowLine(true)
controlsTable.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
controlsTable.SetAutoFormatHeaders(false)
controlsTable.SetUnicodeHVC(tablewriter.Regular, tablewriter.Regular, gchalk.Ansi256(238))
controlRows := generateControlRows(policies)
short := utils.CheckShortTerminalWidth(controlRows, table.Row{"Control ID", "Control name", "Docs", "Frameworks"})
short := utils.CheckShortTerminalWidth(controlRows, []string{"Control ID", "Control name", "Docs", "Frameworks"})
if short {
controlsTable.AppendHeader(table.Row{"Controls"})
controlsTable.SetAutoWrapText(false)
controlsTable.SetHeader([]string{"Controls"})
controlRows = shortFormatControlRows(controlRows)
} else {
controlsTable.AppendHeader(table.Row{"Control ID", "Control name", "Docs", "Frameworks"})
controlsTable.SetHeader([]string{"Control ID", "Control name", "Docs", "Frameworks"})
}
var headerColors []tablewriter.Colors
for range controlRows[0] {
headerColors = append(headerColors, tablewriter.Colors{tablewriter.Bold, tablewriter.FgHiYellowColor})
}
controlsTable.SetHeaderColor(headerColors...)
controlsTable.AppendRows(controlRows)
data := v2.Matrix{}
data = append(data, controlRows...)
controlsTable.AppendBulk(data)
controlsTable.Render()
}
func generateControlRows(policies []string) []table.Row {
rows := make([]table.Row, 0, len(policies))
func generateControlRows(policies []string) [][]string {
rows := [][]string{}
for _, control := range policies {
@@ -168,7 +188,7 @@ func generateControlRows(policies []string) []table.Row {
docs := cautils.GetControlLink(id)
currentRow := table.Row{id, control, docs, strings.ReplaceAll(framework, " ", "\n")}
currentRow := []string{id, control, docs, strings.Replace(framework, " ", "\n", -1)}
rows = append(rows, currentRow)
}
@@ -176,19 +196,20 @@ func generateControlRows(policies []string) []table.Row {
return rows
}
func generatePolicyRows(policies []string) []table.Row {
rows := make([]table.Row, 0, len(policies))
func generatePolicyRows(policies []string) [][]string {
rows := [][]string{}
for _, policy := range policies {
rows = append(rows, table.Row{policy})
currentRow := []string{policy}
rows = append(rows, currentRow)
}
return rows
}
func shortFormatControlRows(controlRows []table.Row) []table.Row {
rows := make([]table.Row, 0, len(controlRows))
func shortFormatControlRows(controlRows [][]string) [][]string {
rows := [][]string{}
for _, controlRow := range controlRows {
rows = append(rows, table.Row{fmt.Sprintf("Control ID"+strings.Repeat(" ", 3)+": %+v\nControl Name"+strings.Repeat(" ", 1)+": %+v\nDocs"+strings.Repeat(" ", 9)+": %+v\nFrameworks"+strings.Repeat(" ", 3)+": %+v", controlRow[0], controlRow[1], controlRow[2], strings.ReplaceAll(controlRow[3].(string), "\n", " "))})
rows = append(rows, []string{fmt.Sprintf("Control ID"+strings.Repeat(" ", 3)+": %+v\nControl Name"+strings.Repeat(" ", 1)+": %+v\nDocs"+strings.Repeat(" ", 9)+": %+v\nFrameworks"+strings.Repeat(" ", 3)+": %+v", controlRow[0], controlRow[1], controlRow[2], strings.Replace(controlRow[3], "\n", " ", -1))})
}
return rows
}

View File

@@ -9,7 +9,6 @@ import (
"sort"
"testing"
"github.com/jedib0t/go-pretty/v6/table"
metav1 "github.com/kubescape/kubescape/v3/core/meta/datastructures/v1"
"github.com/stretchr/testify/assert"
)
@@ -106,7 +105,7 @@ func TestGeneratePolicyRows_NonEmptyPolicyList(t *testing.T) {
result := generatePolicyRows(policies)
// Assert
assert.Equal(t, []table.Row{{"policy1"}, {"policy2"}, {"policy3"}}, result)
assert.Equal(t, [][]string{{"policy1"}, {"policy2"}, {"policy3"}}, result)
}
// Returns an empty 2D slice for an empty list of policies.
@@ -123,12 +122,12 @@ func TestGeneratePolicyRows_EmptyPolicyList(t *testing.T) {
// The function returns a list of rows, each containing a formatted string with control ID, control name, docs, and frameworks.
func TestShortFormatControlRows_ReturnsListOfRowsWithFormattedString(t *testing.T) {
controlRows := []table.Row{
controlRows := [][]string{
{"ID1", "Control 1", "Docs 1", "Framework 1"},
{"ID2", "Control 2", "Docs 2", "Framework 2"},
}
want := []table.Row{
want := [][]string{
{"Control ID : ID1\nControl Name : Control 1\nDocs : Docs 1\nFrameworks : Framework 1"},
{"Control ID : ID2\nControl Name : Control 2\nDocs : Docs 2\nFrameworks : Framework 2"},
}
@@ -140,12 +139,12 @@ func TestShortFormatControlRows_ReturnsListOfRowsWithFormattedString(t *testing.
// The function formats the control rows correctly, replacing newlines in the frameworks column with line breaks.
func TestShortFormatControlRows_FormatsControlRowsCorrectly(t *testing.T) {
controlRows := []table.Row{
controlRows := [][]string{
{"ID1", "Control 1", "Docs 1", "Framework\n1"},
{"ID2", "Control 2", "Docs 2", "Framework\n2"},
}
want := []table.Row{
want := [][]string{
{"Control ID : ID1\nControl Name : Control 1\nDocs : Docs 1\nFrameworks : Framework 1"},
{"Control ID : ID2\nControl Name : Control 2\nDocs : Docs 2\nFrameworks : Framework 2"},
}
@@ -157,11 +156,11 @@ func TestShortFormatControlRows_FormatsControlRowsCorrectly(t *testing.T) {
// The function handles a control row with an empty control ID.
func TestShortFormatControlRows_HandlesControlRowWithEmptyControlID(t *testing.T) {
controlRows := []table.Row{
controlRows := [][]string{
{"", "Control 1", "Docs 1", "Framework 1"},
}
want := []table.Row{
want := [][]string{
{"Control ID : \nControl Name : Control 1\nDocs : Docs 1\nFrameworks : Framework 1"},
}
@@ -172,11 +171,11 @@ func TestShortFormatControlRows_HandlesControlRowWithEmptyControlID(t *testing.T
// The function handles a control row with an empty control name.
func TestShortFormatControlRows_HandlesControlRowWithEmptyControlName(t *testing.T) {
controlRows := []table.Row{
controlRows := [][]string{
{"ID1", "", "Docs 1", "Framework 1"},
}
want := []table.Row{
want := [][]string{
{"Control ID : ID1\nControl Name : \nDocs : Docs 1\nFrameworks : Framework 1"},
}
@@ -193,7 +192,7 @@ func TestGenerateControlRowsWithAllFields(t *testing.T) {
"3|Control 3|Framework 3",
}
want := []table.Row{
want := [][]string{
{"1", "Control 1", "https://hub.armosec.io/docs/1", "Framework\n1"},
{"2", "Control 2", "https://hub.armosec.io/docs/2", "Framework\n2"},
{"3", "Control 3", "https://hub.armosec.io/docs/3", "Framework\n3"},
@@ -216,7 +215,7 @@ func TestGenerateControlRowsHandlesPoliciesWithEmptyStringOrNoPipesOrOnePipeMiss
"5|Control 5||Extra 5",
}
expectedRows := []table.Row{
expectedRows := [][]string{
{"", "", "https://hub.armosec.io/docs/", ""},
{"1", "", "https://hub.armosec.io/docs/1", ""},
{"2", "Control 2", "https://hub.armosec.io/docs/2", "Framework\n2"},
@@ -253,18 +252,18 @@ func TestGenerateTableWithCorrectHeadersAndRows(t *testing.T) {
os.Stdout = rescueStdout
// got := buf.String()
want := `────────────┬──────────────┬───────────────────────────────┬────────────
want := `────────────┬──────────────┬───────────────────────────────┬────────────
│ Control ID │ Control name │ Docs │ Frameworks │
├────────────┼──────────────┼───────────────────────────────┼────────────┤
│ 1 │ Control 1 │ https://hub.armosec.io/docs/1 │ Framework │
│ │ │ │ 1
│ │ │ │ 1
├────────────┼──────────────┼───────────────────────────────┼────────────┤
│ 2 │ Control 2 │ https://hub.armosec.io/docs/2 │ Framework │
│ │ │ │ 2
│ │ │ │ 2
├────────────┼──────────────┼───────────────────────────────┼────────────┤
│ 3 │ Control 3 │ https://hub.armosec.io/docs/3 │ Framework │
│ │ │ │ 3
────────────┴──────────────┴───────────────────────────────┴────────────
│ │ │ │ 3
────────────┴──────────────┴───────────────────────────────┴────────────
`
assert.Equal(t, want, string(got))
@@ -295,7 +294,7 @@ func TestGenerateTableWithMalformedPoliciesAndPrettyPrintHeadersAndRows(t *testi
os.Stdout = rescueStdout
want := `────────────┬──────────────┬───────────────────────────────┬────────────
want := `────────────┬──────────────┬───────────────────────────────┬────────────
│ Control ID │ Control name │ Docs │ Frameworks │
├────────────┼──────────────┼───────────────────────────────┼────────────┤
│ │ │ https://hub.armosec.io/docs/ │ │
@@ -303,18 +302,18 @@ func TestGenerateTableWithMalformedPoliciesAndPrettyPrintHeadersAndRows(t *testi
│ 1 │ │ https://hub.armosec.io/docs/1 │ │
├────────────┼──────────────┼───────────────────────────────┼────────────┤
│ 2 │ Control 2 │ https://hub.armosec.io/docs/2 │ Framework │
│ │ │ │ 2
│ │ │ │ 2
├────────────┼──────────────┼───────────────────────────────┼────────────┤
│ 3 │ Control 3 │ https://hub.armosec.io/docs/3 │ Framework │
│ │ │ │ 3
│ │ │ │ 3
├────────────┼──────────────┼───────────────────────────────┼────────────┤
│ 4 │ │ https://hub.armosec.io/docs/4 │ Framework │
│ │ │ │ 4
│ │ │ │ 4
├────────────┼──────────────┼───────────────────────────────┼────────────┤
│ │ │ https://hub.armosec.io/docs/ │ │
├────────────┼──────────────┼───────────────────────────────┼────────────┤
│ 5 │ Control 5 │ https://hub.armosec.io/docs/5 │ │
────────────┴──────────────┴───────────────────────────────┴────────────
────────────┴──────────────┴───────────────────────────────┴────────────
`
assert.Equal(t, want, string(got))

View File

@@ -1,22 +1,15 @@
package core
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"slices"
"strings"
"time"
"github.com/anchore/clio"
grypejson "github.com/anchore/grype/grype/presenter/json"
"github.com/anchore/grype/grype/presenter"
"github.com/anchore/grype/grype/presenter/models"
copaGrype "github.com/anubhav06/copa-grype/grype"
"github.com/containerd/platforms"
"github.com/docker/buildx/build"
"github.com/docker/cli/cli/config"
"github.com/kubescape/go-logger"
"github.com/kubescape/go-logger/helpers"
"github.com/kubescape/kubescape/v3/core/cautils"
@@ -24,37 +17,21 @@ import (
"github.com/kubescape/kubescape/v3/core/pkg/resultshandling"
"github.com/kubescape/kubescape/v3/core/pkg/resultshandling/printer"
"github.com/kubescape/kubescape/v3/pkg/imagescan"
"github.com/moby/buildkit/client"
"github.com/moby/buildkit/client/llb"
"github.com/moby/buildkit/exporter/containerimage/exptypes"
gwclient "github.com/moby/buildkit/frontend/gateway/client"
"github.com/moby/buildkit/session"
"github.com/moby/buildkit/session/auth/authprovider"
"github.com/project-copacetic/copacetic/pkg/buildkit"
"github.com/project-copacetic/copacetic/pkg/pkgmgr"
"github.com/project-copacetic/copacetic/pkg/types/unversioned"
"github.com/project-copacetic/copacetic/pkg/utils"
"github.com/quay/claircore/osrelease"
log "github.com/sirupsen/logrus"
)
const (
copaProduct = "copa"
)
func (ks *Kubescape) Patch(patchInfo *ksmetav1.PatchInfo, scanInfo *cautils.ScanInfo) (bool, error) {
func (ks *Kubescape) Patch(patchInfo *ksmetav1.PatchInfo, scanInfo *cautils.ScanInfo) (*models.PresenterConfig, error) {
// ===================== Scan the image =====================
logger.L().Start(fmt.Sprintf("Scanning image: %s", patchInfo.Image))
// Setup the scan service
distCfg, installCfg, _ := imagescan.NewDefaultDBConfig()
svc, err := imagescan.NewScanServiceWithMatchers(distCfg, installCfg, scanInfo.UseDefaultMatchers)
if err != nil {
logger.L().StopError(fmt.Sprintf("Failed to initialize image scanner: %s", err))
return false, err
}
defer svc.Close()
dbCfg, _ := imagescan.NewDefaultDBConfig()
svc := imagescan.NewScanService(dbCfg)
creds := imagescan.RegistryCredentials{
Username: patchInfo.Username,
Password: patchInfo.Password,
@@ -62,21 +39,15 @@ func (ks *Kubescape) Patch(patchInfo *ksmetav1.PatchInfo, scanInfo *cautils.Scan
// Scan the image
scanResults, err := svc.Scan(ks.Context(), patchInfo.Image, creds, nil, nil)
if err != nil {
return false, err
}
model, err := models.NewDocument(clio.Identification{}, scanResults.Packages, scanResults.Context,
*scanResults.RemainingMatches, scanResults.IgnoredMatches, scanResults.VulnerabilityProvider, nil, nil, models.DefaultSortStrategy, false)
if err != nil {
return false, fmt.Errorf("failed to create document: %w", err)
return nil, err
}
// If the scan results ID is empty, set it to "grype"
if model.Descriptor.Name == "" {
model.Descriptor.Name = "grype"
if scanResults.ID.Name == "" {
scanResults.ID.Name = "grype"
}
// Save the scan results to a file in json format
pres := grypejson.NewPresenter(models.PresenterConfig{Document: model, SBOM: scanResults.SBOM})
pres := presenter.GetPresenter("json", "", false, *scanResults)
fileName := fmt.Sprintf("%s:%s.json", patchInfo.ImageName, patchInfo.ImageTag)
fileName = strings.ReplaceAll(fileName, "/", "-")
@@ -84,7 +55,7 @@ func (ks *Kubescape) Patch(patchInfo *ksmetav1.PatchInfo, scanInfo *cautils.Scan
writer := printer.GetWriter(ks.Context(), fileName)
if err = pres.Present(writer); err != nil {
return false, err
return nil, err
}
logger.L().StopSuccess(fmt.Sprintf("Successfully scanned image: %s", patchInfo.Image))
@@ -98,7 +69,7 @@ func (ks *Kubescape) Patch(patchInfo *ksmetav1.PatchInfo, scanInfo *cautils.Scan
}
if err = copaPatch(ks.Context(), patchInfo.Timeout, patchInfo.BuildkitAddress, patchInfo.Image, fileName, patchedImageName, "", patchInfo.IgnoreError, patchInfo.BuildKitOpts); err != nil {
return false, err
return nil, err
}
// Restore the output streams
@@ -112,7 +83,7 @@ func (ks *Kubescape) Patch(patchInfo *ksmetav1.PatchInfo, scanInfo *cautils.Scan
scanResultsPatched, err := svc.Scan(ks.Context(), patchedImageName, creds, nil, nil)
if err != nil {
return false, err
return nil, err
}
logger.L().StopSuccess(fmt.Sprintf("Successfully re-scanned image: %s", patchedImageName))
@@ -128,9 +99,14 @@ func (ks *Kubescape) Patch(patchInfo *ksmetav1.PatchInfo, scanInfo *cautils.Scan
outputPrinters := GetOutputPrinters(scanInfo, ks.Context(), "")
uiPrinter := GetUIPrinter(ks.Context(), scanInfo, "")
resultsHandler := resultshandling.NewResultsHandler(nil, outputPrinters, uiPrinter)
resultsHandler.ImageScanData = []cautils.ImageScanData{*scanResultsPatched}
resultsHandler.ImageScanData = []cautils.ImageScanData{
{
PresenterConfig: scanResultsPatched,
Image: patchedImageName,
},
}
return svc.ExceedsSeverityThreshold(imagescan.ParseSeverity(scanInfo.FailThresholdSeverity), scanResultsPatched.Matches), resultsHandler.HandleResults(ks.Context(), scanInfo)
return scanResultsPatched, resultsHandler.HandleResults(ks.Context())
}
func disableCopaLogger() {
@@ -184,183 +160,41 @@ func patchWithContext(ctx context.Context, buildkitAddr, image, reportFile, patc
}
}
var updates *unversioned.UpdateManifest
// Parse report for update packages
updates, err := tryParseScanReport(reportFile)
if err != nil {
return err
}
bkClient, err := buildkit.NewClient(ctx, bkOpts)
client, err := buildkit.NewClient(ctx, bkOpts)
if err != nil {
return fmt.Errorf("copa: error creating buildkit client :: %w", err)
return err
}
defer bkClient.Close()
defer client.Close()
dockerConfig := config.LoadDefaultConfigFile(os.Stderr)
cfg := authprovider.DockerAuthProviderConfig{ConfigFile: dockerConfig}
attachable := []session.Attachable{authprovider.NewDockerAuthProvider(cfg)}
solveOpt := client.SolveOpt{
Exports: []client.ExportEntry{
{
Type: client.ExporterImage,
Attrs: map[string]string{
"name": patchedImageName,
"push": "true",
},
},
},
Frontend: "", // i.e. we are passing in the llb.Definition directly
Session: attachable, // used for authprovider, sshagentprovider and secretprovider
}
solveOpt.SourcePolicy, err = build.ReadSourcePolicy()
// Configure buildctl/client for use by package manager
config, err := buildkit.InitializeBuildkitConfig(ctx, client, image, updates)
if err != nil {
return fmt.Errorf("copa: error reading source policy :: %w", err)
return err
}
buildChannel := make(chan *client.SolveStatus)
_, err = bkClient.Build(ctx, solveOpt, copaProduct, func(ctx context.Context, c gwclient.Client) (*gwclient.Result, error) {
// Configure buildctl/client for use by package manager
config, err := buildkit.InitializeBuildkitConfig(ctx, c, image)
if err != nil {
return nil, fmt.Errorf("copa: error initializing buildkit config for image %s :: %w", image, err)
}
// Create package manager helper
var manager pkgmgr.PackageManager
if reportFile == "" {
// determine OS family
fileBytes, err := buildkit.ExtractFileFromState(ctx, c, &config.ImageState, "/etc/os-release")
if err != nil {
return nil, fmt.Errorf("unable to extract /etc/os-release file from state %w", err)
}
osType, err := getOSType(ctx, fileBytes)
if err != nil {
return nil, fmt.Errorf("copa: error getting os type :: %w", err)
}
osVersion, err := getOSVersion(ctx, fileBytes)
if err != nil {
return nil, fmt.Errorf("copa: error getting os version :: %w", err)
}
// get package manager based on os family type
manager, err = pkgmgr.GetPackageManager(osType, osVersion, config, workingFolder)
if err != nil {
return nil, fmt.Errorf("copa: error getting package manager for ostype=%s, version=%s :: %w", osType, osVersion, err)
}
// do not specify updates, will update all
updates = nil
} else {
// get package manager based on os family type
manager, err = pkgmgr.GetPackageManager(updates.Metadata.OS.Type, updates.Metadata.OS.Version, config, workingFolder)
if err != nil {
return nil, fmt.Errorf("copa: error getting package manager by family type: ostype=%s, osversion=%s :: %w", updates.Metadata.OS.Type, updates.Metadata.OS.Version, err)
}
}
// Export the patched image state to Docker
// TODO: Add support for other output modes as buildctl does.
log.Infof("Patching %d vulnerabilities", len(updates.Updates))
patchedImageState, errPkgs, err := manager.InstallUpdates(ctx, updates, ignoreError)
log.Infof("Error is: %v", err)
if err != nil {
return nil, nil
}
platform := platforms.Normalize(platforms.DefaultSpec())
if platform.OS != "linux" {
platform.OS = "linux"
}
def, err := patchedImageState.Marshal(ctx, llb.Platform(platform))
if err != nil {
return nil, err
}
res, err := c.Solve(ctx, gwclient.SolveRequest{
Definition: def.ToPB(),
Evaluate: true,
})
if err != nil {
return nil, err
}
res.AddMeta(exptypes.ExporterImageConfigKey, config.ConfigData)
// Currently can only validate updates if updating via scanner
if reportFile != "" {
// create a new manifest with the successfully patched packages
validatedManifest := &unversioned.UpdateManifest{
Metadata: unversioned.Metadata{
OS: unversioned.OS{
Type: updates.Metadata.OS.Type,
Version: updates.Metadata.OS.Version,
},
Config: unversioned.Config{
Arch: updates.Metadata.Config.Arch,
},
},
Updates: []unversioned.UpdatePackage{},
}
for _, update := range updates.Updates {
if !slices.Contains(errPkgs, update.Name) {
validatedManifest.Updates = append(validatedManifest.Updates, update)
}
}
}
return res, nil
}, buildChannel)
return err
}
func getOSType(ctx context.Context, osreleaseBytes []byte) (string, error) {
r := bytes.NewReader(osreleaseBytes)
osData, err := osrelease.Parse(ctx, r)
// Create package manager helper
pkgmgr, err := pkgmgr.GetPackageManager(updates.Metadata.OS.Type, config, workingFolder)
if err != nil {
return "", fmt.Errorf("unable to parse os-release data %w", err)
return err
}
osType := strings.ToLower(osData["NAME"])
switch {
case strings.Contains(osType, "alpine"):
return "alpine", nil
case strings.Contains(osType, "debian"):
return "debian", nil
case strings.Contains(osType, "ubuntu"):
return "ubuntu", nil
case strings.Contains(osType, "amazon"):
return "amazon", nil
case strings.Contains(osType, "centos"):
return "centos", nil
case strings.Contains(osType, "mariner"):
return "cbl-mariner", nil
case strings.Contains(osType, "azure linux"):
return "azurelinux", nil
case strings.Contains(osType, "red hat"):
return "redhat", nil
case strings.Contains(osType, "rocky"):
return "rocky", nil
case strings.Contains(osType, "oracle"):
return "oracle", nil
case strings.Contains(osType, "alma"):
return "alma", nil
default:
log.Error("unsupported osType ", osType)
return "", errors.ErrUnsupported
}
}
func getOSVersion(ctx context.Context, osreleaseBytes []byte) (string, error) {
r := bytes.NewReader(osreleaseBytes)
osData, err := osrelease.Parse(ctx, r)
// Export the patched image state to Docker
patchedImageState, _, err := pkgmgr.InstallUpdates(ctx, updates, ignoreError)
if err != nil {
return "", fmt.Errorf("unable to parse os-release data %w", err)
return err
}
return osData["VERSION_ID"], nil
if err = buildkit.SolveToDocker(ctx, config.Client, patchedImageState, config.ConfigData, patchedImageName); err != nil {
return err
}
return nil
}
// This function adds support to copa for patching Kubescape produced results

View File

@@ -3,8 +3,8 @@ package core
import (
"context"
"fmt"
"slices"
mapset "github.com/deckarep/golang-set/v2"
"github.com/kubescape/backend/pkg/versioncheck"
"github.com/kubescape/go-logger"
"github.com/kubescape/go-logger/helpers"
@@ -66,11 +66,9 @@ func getInterfaces(ctx context.Context, scanInfo *cautils.ScanInfo) componentInt
}
// ================== version testing ======================================
// Skip version check in air-gapped mode (when keep-local flag is set)
if !scanInfo.Local {
v := versioncheck.NewIVersionCheckHandler(ctx)
_ = v.CheckLatestVersion(ctx, versioncheck.NewVersionCheckRequest(scanInfo.AccountID, versioncheck.BuildNumber, policyIdentifierIdentities(scanInfo.PolicyIdentifier), "", string(scanInfo.GetScanningContext()), k8sClient))
}
v := versioncheck.NewIVersionCheckHandler(ctx)
_ = v.CheckLatestVersion(ctx, versioncheck.NewVersionCheckRequest(scanInfo.AccountID, versioncheck.BuildNumber, policyIdentifierIdentities(scanInfo.PolicyIdentifier), "", string(scanInfo.GetScanningContext()), k8sClient))
// ================== setup host scanner object ======================================
ctxHostScanner, spanHostScanner := otel.Tracer("").Start(ctx, "setup host scanner")
@@ -134,25 +132,17 @@ func (ks *Kubescape) Scan(scanInfo *cautils.ScanInfo) (*resultshandling.ResultsH
interfaces := getInterfaces(ctxInit, scanInfo)
interfaces.report.SetTenantConfig(interfaces.tenantConfig)
// Only create DownloadReleasedPolicy if not in air-gapped mode
var downloadReleasedPolicy *getter.DownloadReleasedPolicy
if isAirGappedMode(scanInfo) {
// In air-gapped mode (--keep-local or using local files via --use-from, --controls-config, --exceptions, or attack tracks),
// don't initialize the downloader to prevent network access
downloadReleasedPolicy = nil
} else {
downloadReleasedPolicy = getter.NewDownloadReleasedPolicy() // download config inputs from github release
}
downloadReleasedPolicy := getter.NewDownloadReleasedPolicy() // download config inputs from github release
// set policy getter only after setting the customerGUID
scanInfo.PolicyGetter = getPolicyGetter(ctxInit, scanInfo.UseFrom, interfaces.tenantConfig.GetAccountID(), scanInfo.FrameworkScan, downloadReleasedPolicy)
scanInfo.ControlsInputsGetter = getConfigInputsGetter(ctxInit, scanInfo.ControlsInputs, interfaces.tenantConfig.GetAccountID(), downloadReleasedPolicy)
scanInfo.ExceptionsGetter = getExceptionsGetter(ctxInit, scanInfo.UseExceptions, interfaces.tenantConfig.GetAccountID(), downloadReleasedPolicy)
scanInfo.AttackTracksGetter = getAttackTracksGetter(ctxInit, scanInfo.AttackTracks, interfaces.tenantConfig.GetAccountID(), downloadReleasedPolicy)
scanInfo.Getters.PolicyGetter = getPolicyGetter(ctxInit, scanInfo.UseFrom, interfaces.tenantConfig.GetAccountID(), scanInfo.FrameworkScan, downloadReleasedPolicy)
scanInfo.Getters.ControlsInputsGetter = getConfigInputsGetter(ctxInit, scanInfo.ControlsInputs, interfaces.tenantConfig.GetAccountID(), downloadReleasedPolicy)
scanInfo.Getters.ExceptionsGetter = getExceptionsGetter(ctxInit, scanInfo.UseExceptions, interfaces.tenantConfig.GetAccountID(), downloadReleasedPolicy)
scanInfo.Getters.AttackTracksGetter = getAttackTracksGetter(ctxInit, scanInfo.AttackTracks, interfaces.tenantConfig.GetAccountID(), downloadReleasedPolicy)
// TODO - list supported frameworks/controls
if scanInfo.ScanAll {
scanInfo.SetPolicyIdentifiers(listFrameworksNames(scanInfo.PolicyGetter), apisv1.KindFramework)
scanInfo.SetPolicyIdentifiers(listFrameworksNames(scanInfo.Getters.PolicyGetter), apisv1.KindFramework)
}
// remove host scanner components
@@ -200,7 +190,7 @@ func (ks *Kubescape) Scan(scanInfo *cautils.ScanInfo) (*resultshandling.ResultsH
// ======================== prioritization ===================
if scanInfo.PrintAttackTree || isPrioritizationScanType(scanInfo.ScanType) {
_, spanPrioritization := otel.Tracer("").Start(ctxOpa, "prioritization")
if priotizationHandler, err := resourcesprioritization.NewResourcesPrioritizationHandler(ctxOpa, scanInfo.AttackTracksGetter, scanInfo.PrintAttackTree); err != nil {
if priotizationHandler, err := resourcesprioritization.NewResourcesPrioritizationHandler(ctxOpa, scanInfo.Getters.AttackTracksGetter, scanInfo.PrintAttackTree); err != nil {
logger.L().Ctx(ks.Context()).Warning("failed to get attack tracks, this may affect the scanning results", helpers.Error(err))
} else if err := priotizationHandler.PrioritizeResources(scanData); err != nil {
return resultsHandling, fmt.Errorf("%w", err)
@@ -212,7 +202,7 @@ func (ks *Kubescape) Scan(scanInfo *cautils.ScanInfo) (*resultshandling.ResultsH
}
if scanInfo.ScanImages {
scanImages(scanInfo.ScanType, scanData, ks.Context(), resultsHandling, scanInfo)
scanImages(scanInfo.ScanType, scanData, ks.Context(), resultsHandling)
}
// ========================= results handling =====================
resultsHandling.SetData(scanData)
@@ -224,8 +214,8 @@ func (ks *Kubescape) Scan(scanInfo *cautils.ScanInfo) (*resultshandling.ResultsH
return resultsHandling, nil
}
func scanImages(scanType cautils.ScanTypes, scanData *cautils.OPASessionObj, ctx context.Context, resultsHandling *resultshandling.ResultsHandler, scanInfo *cautils.ScanInfo) {
imagesToScan := mapset.NewSet[string]()
func scanImages(scanType cautils.ScanTypes, scanData *cautils.OPASessionObj, ctx context.Context, resultsHandling *resultshandling.ResultsHandler) {
var imagesToScan []string
if scanType == cautils.ScanTypeWorkload {
containers, err := workloadinterface.NewWorkloadObj(scanData.SingleResourceScan.GetObject()).GetContainers()
@@ -234,7 +224,9 @@ func scanImages(scanType cautils.ScanTypes, scanData *cautils.OPASessionObj, ctx
return
}
for _, container := range containers {
imagesToScan.Add(container.Image)
if !slices.Contains(imagesToScan, container.Image) {
imagesToScan = append(imagesToScan, container.Image)
}
}
} else {
for _, workload := range scanData.AllResources {
@@ -244,20 +236,17 @@ func scanImages(scanType cautils.ScanTypes, scanData *cautils.OPASessionObj, ctx
continue
}
for _, container := range containers {
imagesToScan.Add(container.Image)
if !slices.Contains(imagesToScan, container.Image) {
imagesToScan = append(imagesToScan, container.Image)
}
}
}
}
distCfg, installCfg, _ := imagescan.NewDefaultDBConfig()
svc, err := imagescan.NewScanServiceWithMatchers(distCfg, installCfg, scanInfo.UseDefaultMatchers)
if err != nil {
logger.L().StopError(fmt.Sprintf("Failed to initialize image scanner: %s", err))
return
}
defer svc.Close()
dbCfg, _ := imagescan.NewDefaultDBConfig()
svc := imagescan.NewScanService(dbCfg)
for img := range imagesToScan.Iter() {
for _, img := range imagesToScan {
logger.L().Start("Scanning", helpers.String("image", img))
if err := scanSingleImage(ctx, img, svc, resultsHandling); err != nil {
logger.L().StopError("failed to scan", helpers.String("image", img), helpers.Error(err))
@@ -266,27 +255,20 @@ func scanImages(scanType cautils.ScanTypes, scanData *cautils.OPASessionObj, ctx
}
}
func scanSingleImage(ctx context.Context, img string, svc *imagescan.Service, resultsHandling *resultshandling.ResultsHandler) error {
func scanSingleImage(ctx context.Context, img string, svc imagescan.Service, resultsHandling *resultshandling.ResultsHandler) error {
scanResults, err := svc.Scan(ctx, img, imagescan.RegistryCredentials{}, nil, nil)
if err != nil {
return err
}
resultsHandling.ImageScanData = append(resultsHandling.ImageScanData, *scanResults)
resultsHandling.ImageScanData = append(resultsHandling.ImageScanData, cautils.ImageScanData{
Image: img,
PresenterConfig: scanResults,
})
return nil
}
func isPrioritizationScanType(scanType cautils.ScanTypes) bool {
return scanType == cautils.ScanTypeCluster || scanType == cautils.ScanTypeRepo
}
// isAirGappedMode returns true if the scan is configured to run in air-gapped mode
// (i.e., without any network access to download policies, exceptions, or other artifacts)
func isAirGappedMode(scanInfo *cautils.ScanInfo) bool {
return scanInfo.Local ||
len(scanInfo.UseFrom) > 0 ||
scanInfo.ControlsInputs != "" ||
scanInfo.UseExceptions != "" ||
scanInfo.AttackTracks != ""
}

View File

@@ -58,66 +58,3 @@ func TestIsPrioritizationScanType(t *testing.T) {
})
}
}
func TestIsAirGappedMode(t *testing.T) {
tests := []struct {
name string
scanInfo *cautils.ScanInfo
want bool
}{
{
name: "air-gapped with Local flag",
scanInfo: &cautils.ScanInfo{
Local: true,
},
want: true,
},
{
name: "air-gapped with UseFrom",
scanInfo: &cautils.ScanInfo{
UseFrom: []string{"/path/to/policy"},
},
want: true,
},
{
name: "air-gapped with ControlsInputs",
scanInfo: &cautils.ScanInfo{
ControlsInputs: "/path/to/controls",
},
want: true,
},
{
name: "air-gapped with UseExceptions",
scanInfo: &cautils.ScanInfo{
UseExceptions: "/path/to/exceptions",
},
want: true,
},
{
name: "air-gapped with AttackTracks",
scanInfo: &cautils.ScanInfo{
AttackTracks: "/path/to/attack-tracks",
},
want: true,
},
{
name: "not air-gapped - all empty",
scanInfo: &cautils.ScanInfo{},
want: false,
},
{
name: "air-gapped with multiple flags",
scanInfo: &cautils.ScanInfo{
Local: true,
UseFrom: []string{"/path/to/policy"},
},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, isAirGappedMode(tt.scanInfo))
})
}
}

View File

@@ -1,9 +1,8 @@
package v1
type ImageScanInfo struct {
Username string
Password string
Image string
Exceptions string
UseDefaultMatchers bool
Username string
Password string
Image string
Exceptions string
}

View File

@@ -3,6 +3,7 @@ package meta
import (
"context"
"github.com/anchore/grype/grype/presenter/models"
"github.com/kubescape/kubescape/v3/core/cautils"
metav1 "github.com/kubescape/kubescape/v3/core/meta/datastructures/v1"
"github.com/kubescape/kubescape/v3/core/pkg/resultshandling"
@@ -26,8 +27,8 @@ type IKubescape interface {
Fix(fixInfo *metav1.FixInfo) error
// patch
Patch(patchInfo *metav1.PatchInfo, scanInfo *cautils.ScanInfo) (bool, error)
Patch(patchInfo *metav1.PatchInfo, scanInfo *cautils.ScanInfo) (*models.PresenterConfig, error)
// scan image
ScanImage(imgScanInfo *metav1.ImageScanInfo, scanInfo *cautils.ScanInfo) (bool, error)
ScanImage(imgScanInfo *metav1.ImageScanInfo, scanInfo *cautils.ScanInfo) (*models.PresenterConfig, error)
}

View File

@@ -3,6 +3,7 @@ package mocks
import (
"context"
"github.com/anchore/grype/grype/presenter/models"
"github.com/kubescape/kubescape/v3/core/cautils"
metav1 "github.com/kubescape/kubescape/v3/core/meta/datastructures/v1"
"github.com/kubescape/kubescape/v3/core/pkg/resultshandling"
@@ -14,38 +15,38 @@ func (m *MockIKubescape) Context() context.Context {
return context.TODO()
}
func (m *MockIKubescape) Scan(_ *cautils.ScanInfo) (*resultshandling.ResultsHandler, error) {
func (m *MockIKubescape) Scan(scanInfo *cautils.ScanInfo) (*resultshandling.ResultsHandler, error) {
return nil, nil
}
func (m *MockIKubescape) List(_ *metav1.ListPolicies) error {
func (m *MockIKubescape) List(listPolicies *metav1.ListPolicies) error {
return nil
}
func (m *MockIKubescape) Download(_ *metav1.DownloadInfo) error {
func (m *MockIKubescape) Download(downloadInfo *metav1.DownloadInfo) error {
return nil
}
func (m *MockIKubescape) SetCachedConfig(_ *metav1.SetConfig) error {
func (m *MockIKubescape) SetCachedConfig(setConfig *metav1.SetConfig) error {
return nil
}
func (m *MockIKubescape) ViewCachedConfig(_ *metav1.ViewConfig) error {
func (m *MockIKubescape) ViewCachedConfig(viewConfig *metav1.ViewConfig) error {
return nil
}
func (m *MockIKubescape) DeleteCachedConfig(_ *metav1.DeleteConfig) error {
func (m *MockIKubescape) DeleteCachedConfig(deleteConfig *metav1.DeleteConfig) error {
return nil
}
func (m *MockIKubescape) Fix(_ *metav1.FixInfo) error {
func (m *MockIKubescape) Fix(fixInfo *metav1.FixInfo) error {
return nil
}
func (m *MockIKubescape) Patch(_ *metav1.PatchInfo, _ *cautils.ScanInfo) (bool, error) {
return false, nil
func (m *MockIKubescape) Patch(patchInfo *metav1.PatchInfo, scanInfo *cautils.ScanInfo) (*models.PresenterConfig, error) {
return nil, nil
}
func (m *MockIKubescape) ScanImage(_ *metav1.ImageScanInfo, _ *cautils.ScanInfo) (bool, error) {
return false, nil
func (m *MockIKubescape) ScanImage(imgScanInfo *metav1.ImageScanInfo, scanInfo *cautils.ScanInfo) (*models.PresenterConfig, error) {
return nil, nil
}

View File

@@ -46,6 +46,8 @@ var hash = []rune("abcdef0123456789")
var nums = []rune("0123456789")
func randSeq(n int, bank []rune) string {
rand.Seed(time.Now().UnixNano())
b := make([]rune, n)
for i := range b {
b[i] = bank[rand.Intn(len(bank))] //nolint:gosec

View File

@@ -88,6 +88,6 @@ type PkgFiles []PackageFile
func (v *ScanResultReport) AsFNVHash() string {
hasher := fnv.New64a()
fmt.Fprintf(hasher, "%v", *v)
hasher.Write([]byte(fmt.Sprintf("%v", *v)))
return fmt.Sprintf("%v", hasher.Sum64())
}

View File

@@ -209,7 +209,7 @@ func (h *FixHandler) ApplyChanges(ctx context.Context, resourcesToFix []Resource
fixedYamlString, err := ApplyFixToContent(ctx, fileAsString, yamlExpression)
if err != nil {
errors = append(errors, fmt.Errorf("failed to fix file %s: %w ", filepath, err))
errors = append(errors, fmt.Errorf("Failed to fix file %s: %w ", filepath, err))
continue
} else {
updatedFiles[filepath] = true
@@ -344,7 +344,7 @@ func GetFileString(filepath string) (string, error) {
bytes, err := os.ReadFile(filepath)
if err != nil {
return "", fmt.Errorf("error reading file %s", filepath)
return "", fmt.Errorf("Error reading file %s", filepath)
}
return string(bytes), nil
@@ -354,7 +354,7 @@ func writeFixesToFile(filepath, content string) error {
err := os.WriteFile(filepath, []byte(content), 0644) //nolint:gosec
if err != nil {
return fmt.Errorf("error writing fixes to file: %w", err)
return fmt.Errorf("Error writing fixes to file: %w", err)
}
return nil

View File

@@ -26,7 +26,7 @@ func decodeDocumentRoots(yamlAsString string) ([]yaml.Node, error) {
break
}
if err != nil {
return nil, fmt.Errorf("cannot decode file as YAML")
return nil, fmt.Errorf("Cannot Decode File as YAML")
}
@@ -55,7 +55,7 @@ func getFixedNodes(ctx context.Context, yamlAsString, yamlExpression string) ([]
fixedCandidateNodes, err := allAtOnceEvaluator.EvaluateCandidateNodes(yamlExpression, allDocuments)
if err != nil {
return nil, fmt.Errorf("error fixing YAML, %w", err)
return nil, fmt.Errorf("Error fixing YAML, %w", err)
}
fixedNodes := make([]yaml.Node, 0)

View File

@@ -86,7 +86,7 @@ func adjustFixedListLines(originalList, fixedList *[]nodeInfo) {
func enocodeIntoYaml(parentNode *yaml.Node, nodeList *[]nodeInfo, tracker int) (string, error) {
if tracker < 0 || tracker >= len(*nodeList) {
return "", fmt.Errorf("index out of range for nodeList: tracker=%d, length=%d", tracker, len(*nodeList))
return "", fmt.Errorf("Index out of range for nodeList: tracker=%d, length=%d", tracker, len(*nodeList))
}
content := make([]*yaml.Node, 0)
@@ -112,11 +112,11 @@ func enocodeIntoYaml(parentNode *yaml.Node, nodeList *[]nodeInfo, tracker int) (
errorEncoding := encoder.Encode(parentForContent)
if errorEncoding != nil {
return "", fmt.Errorf("error debugging node, %v", errorEncoding.Error())
return "", fmt.Errorf("Error debugging node, %v", errorEncoding.Error())
}
errorClosingEncoder := encoder.Close()
if errorClosingEncoder != nil {
return "", fmt.Errorf("error closing encoder: %v", errorClosingEncoder.Error())
return "", fmt.Errorf("Error closing encoder: %v", errorClosingEncoder.Error())
}
return fmt.Sprintf(`%v`, buf.String()), nil
}
@@ -216,7 +216,7 @@ func getLastLineOfResource(linesSlice *[]string, currentLine int) (int, error) {
}
}
return 0, fmt.Errorf("provided line is greater than the length of YAML file")
return 0, fmt.Errorf("Provided line is greater than the length of YAML file")
}
func getNodeLine(nodeList *[]nodeInfo, tracker int) int {
@@ -300,7 +300,7 @@ func isEmptyLineOrComment(lineContent string) bool {
func readDocuments(ctx context.Context, reader io.Reader, decoder yqlib.Decoder) (*list.List, error) {
err := decoder.Init(reader)
if err != nil {
return nil, fmt.Errorf("error initializing the decoder, %w", err)
return nil, fmt.Errorf("Error Initializing the decoder, %w", err)
}
inputList := list.New()
@@ -316,7 +316,7 @@ func readDocuments(ctx context.Context, reader io.Reader, decoder yqlib.Decoder)
}
return inputList, nil
} else if errorReading != nil {
return nil, fmt.Errorf("error decoding yaml file, %w", errorReading)
return nil, fmt.Errorf("Error Decoding YAML file, %w", errorReading)
}
candidateNode.Document = currentIndex

View File

@@ -434,9 +434,9 @@ func TestRemoveOutOfRangeLines(t *testing.T) {
func TestShouldCalculateTotalNumberOfChildrenAndAddToCurrentTracker(t *testing.T) {
node := &yaml.Node{
Content: []*yaml.Node{
{},
{},
{},
&yaml.Node{},
&yaml.Node{},
&yaml.Node{},
},
}
currentTracker := 5

View File

@@ -0,0 +1,75 @@
apiVersion: v1
kind: Namespace
metadata:
labels:
app: kubescape-host-scanner
k8s-app: kubescape-host-scanner
kubernetes.io/metadata.name: kubescape-host-scanner
tier: kubescape-host-scanner-control-plane
name: kubescape
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: host-scanner
namespace: kubescape
labels:
app: host-scanner
k8s-app: kubescape-host-scanner
otel: enabled
spec:
selector:
matchLabels:
name: host-scanner
template:
metadata:
labels:
name: host-scanner
spec:
tolerations:
# this toleration is to have the DaemonDet runnable on all nodes (including masters)
# remove it if your masters can't run pods
- operator: Exists
containers:
- name: host-sensor
image: quay.io/kubescape/host-scanner:v1.0.61
securityContext:
allowPrivilegeEscalation: true
privileged: true
readOnlyRootFilesystem: true
procMount: Unmasked
ports:
- name: scanner # Do not change port name
containerPort: 7888
protocol: TCP
resources:
limits:
cpu: 0.1m
memory: 200Mi
requests:
cpu: 1m
memory: 200Mi
volumeMounts:
- mountPath: /host_fs
name: host-filesystem
startupProbe:
httpGet:
path: /readyz
port: 7888
failureThreshold: 30
periodSeconds: 1
livenessProbe:
httpGet:
path: /healthz
port: 7888
periodSeconds: 10
terminationGracePeriodSeconds: 120
dnsPolicy: ClusterFirstWithHostNet
automountServiceAccountToken: false
volumes:
- hostPath:
path: /
type: Directory
name: host-filesystem
hostPID: true
hostIPC: true

View File

@@ -18,5 +18,6 @@ func TestHostSensorHandlerMock(t *testing.T) {
require.Nil(t, status)
require.NoError(t, err)
require.Empty(t, h.GetNamespace())
require.NoError(t, h.TearDown())
}

View File

@@ -1,235 +0,0 @@
package hostsensorutils
import (
"context"
stdjson "encoding/json"
"fmt"
"reflect"
"github.com/kubescape/go-logger"
"github.com/kubescape/go-logger/helpers"
k8shostsensor "github.com/kubescape/k8s-interface/hostsensor"
"github.com/kubescape/k8s-interface/k8sinterface"
"github.com/kubescape/opa-utils/objectsenvelopes/hostsensor"
"github.com/kubescape/opa-utils/reporthandling/apis"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// getCRDResources retrieves resources from CRDs and converts them to HostSensorDataEnvelope format
func (hsh *HostSensorHandler) getCRDResources(ctx context.Context, resourceType k8shostsensor.HostSensorResource) ([]hostsensor.HostSensorDataEnvelope, error) {
pluralName := k8shostsensor.MapResourceToPlural(resourceType)
if pluralName == "" {
return nil, fmt.Errorf("unsupported resource type: %s", resourceType)
}
// List CRD resources
items, err := hsh.listCRDResources(ctx, pluralName, resourceType.String())
if err != nil {
return nil, err
}
// Convert to HostSensorDataEnvelope format
result := make([]hostsensor.HostSensorDataEnvelope, 0, len(items))
for _, item := range items {
envelope, err := hsh.convertCRDToEnvelope(item, resourceType)
if err != nil {
logger.L().Warning("Failed to convert CRD to envelope",
helpers.String("kind", resourceType.String()),
helpers.String("name", item.GetName()),
helpers.Error(err))
continue
}
result = append(result, envelope)
}
logger.L().Debug("Retrieved resources from CRDs",
helpers.String("kind", resourceType.String()),
helpers.Int("count", len(result)))
return result, nil
}
// convertCRDToEnvelope converts a CRD unstructured object to HostSensorDataEnvelope
func (hsh *HostSensorHandler) convertCRDToEnvelope(item unstructured.Unstructured, resourceType k8shostsensor.HostSensorResource) (hostsensor.HostSensorDataEnvelope, error) {
envelope := hostsensor.HostSensorDataEnvelope{}
// Set API version and kind
envelope.SetApiVersion(k8sinterface.JoinGroupVersion(hostsensor.GroupHostSensor, hostsensor.Version))
envelope.SetKind(resourceType.String())
// Set name (node name)
nodeName := item.GetName()
envelope.SetName(nodeName)
// Extract content from spec.content
content, found, err := unstructured.NestedString(item.Object, "spec", "content")
if err != nil {
return envelope, fmt.Errorf("failed to extract spec.content: %w", err)
}
if !found {
// fallback to "spec" itself
contentI, found, err := unstructured.NestedFieldNoCopy(item.Object, "spec")
if err != nil {
return envelope, fmt.Errorf("failed to extract spec: %w", err)
}
if !found {
return envelope, fmt.Errorf("spec not found in CRD")
}
contentBytes, err := stdjson.Marshal(contentI)
if err != nil {
return envelope, fmt.Errorf("failed to marshal spec: %w", err)
}
content = string(contentBytes)
}
// Set data as raw bytes
envelope.SetData([]byte(content))
return envelope, nil
}
// getOsReleaseFile returns the list of osRelease metadata from CRDs.
func (hsh *HostSensorHandler) getOsReleaseFile(ctx context.Context) ([]hostsensor.HostSensorDataEnvelope, error) {
return hsh.getCRDResources(ctx, k8shostsensor.OsReleaseFile)
}
// getKernelVersion returns the list of kernelVersion metadata from CRDs.
func (hsh *HostSensorHandler) getKernelVersion(ctx context.Context) ([]hostsensor.HostSensorDataEnvelope, error) {
return hsh.getCRDResources(ctx, k8shostsensor.KernelVersion)
}
// getLinuxSecurityHardeningStatus returns the list of LinuxSecurityHardeningStatus metadata from CRDs.
func (hsh *HostSensorHandler) getLinuxSecurityHardeningStatus(ctx context.Context) ([]hostsensor.HostSensorDataEnvelope, error) {
return hsh.getCRDResources(ctx, k8shostsensor.LinuxSecurityHardeningStatus)
}
// getOpenPortsList returns the list of open ports from CRDs.
func (hsh *HostSensorHandler) getOpenPortsList(ctx context.Context) ([]hostsensor.HostSensorDataEnvelope, error) {
return hsh.getCRDResources(ctx, k8shostsensor.OpenPortsList)
}
// getKernelVariables returns the list of Linux Kernel variables from CRDs.
func (hsh *HostSensorHandler) getKernelVariables(ctx context.Context) ([]hostsensor.HostSensorDataEnvelope, error) {
return hsh.getCRDResources(ctx, k8shostsensor.LinuxKernelVariables)
}
// getKubeletInfo returns the list of kubelet metadata from CRDs.
func (hsh *HostSensorHandler) getKubeletInfo(ctx context.Context) ([]hostsensor.HostSensorDataEnvelope, error) {
return hsh.getCRDResources(ctx, k8shostsensor.KubeletInfo)
}
// getKubeProxyInfo returns the list of kubeProxy metadata from CRDs.
func (hsh *HostSensorHandler) getKubeProxyInfo(ctx context.Context) ([]hostsensor.HostSensorDataEnvelope, error) {
return hsh.getCRDResources(ctx, k8shostsensor.KubeProxyInfo)
}
// getControlPlaneInfo returns the list of controlPlaneInfo metadata from CRDs.
func (hsh *HostSensorHandler) getControlPlaneInfo(ctx context.Context) ([]hostsensor.HostSensorDataEnvelope, error) {
return hsh.getCRDResources(ctx, k8shostsensor.ControlPlaneInfo)
}
// getCloudProviderInfo returns the list of cloudProviderInfo metadata from CRDs.
func (hsh *HostSensorHandler) getCloudProviderInfo(ctx context.Context) ([]hostsensor.HostSensorDataEnvelope, error) {
return hsh.getCRDResources(ctx, k8shostsensor.CloudProviderInfo)
}
// getCNIInfo returns the list of CNI metadata from CRDs.
func (hsh *HostSensorHandler) getCNIInfo(ctx context.Context) ([]hostsensor.HostSensorDataEnvelope, error) {
return hsh.getCRDResources(ctx, k8shostsensor.CNIInfo)
}
// hasCloudProviderInfo iterates over the []hostsensor.HostSensorDataEnvelope list to find info about the cloud provider.
//
// If information are found, then return true. Return false otherwise.
func hasCloudProviderInfo(cpi []hostsensor.HostSensorDataEnvelope) bool {
for index := range cpi {
if !reflect.DeepEqual(cpi[index].GetData(), stdjson.RawMessage("{}\\n")) {
return true
}
}
return false
}
// CollectResources collects all required information from CRDs.
func (hsh *HostSensorHandler) CollectResources(ctx context.Context) ([]hostsensor.HostSensorDataEnvelope, map[string]apis.StatusInfo, error) {
res := make([]hostsensor.HostSensorDataEnvelope, 0)
infoMap := make(map[string]apis.StatusInfo)
logger.L().Debug("Collecting host sensor data from CRDs")
var hasCloudProvider bool
for _, toPin := range []struct {
Resource k8shostsensor.HostSensorResource
Query func(context.Context) ([]hostsensor.HostSensorDataEnvelope, error)
}{
// queries to CRDs
{
Resource: k8shostsensor.OsReleaseFile,
Query: hsh.getOsReleaseFile,
},
{
Resource: k8shostsensor.KernelVersion,
Query: hsh.getKernelVersion,
},
{
Resource: k8shostsensor.LinuxSecurityHardeningStatus,
Query: hsh.getLinuxSecurityHardeningStatus,
},
{
Resource: k8shostsensor.OpenPortsList,
Query: hsh.getOpenPortsList,
},
{
Resource: k8shostsensor.LinuxKernelVariables,
Query: hsh.getKernelVariables,
},
{
Resource: k8shostsensor.KubeletInfo,
Query: hsh.getKubeletInfo,
},
{
Resource: k8shostsensor.KubeProxyInfo,
Query: hsh.getKubeProxyInfo,
},
{
Resource: k8shostsensor.CloudProviderInfo,
Query: hsh.getCloudProviderInfo,
},
{
Resource: k8shostsensor.CNIInfo,
Query: hsh.getCNIInfo,
},
{
// ControlPlaneInfo is queried _after_ CloudProviderInfo.
Resource: k8shostsensor.ControlPlaneInfo,
Query: hsh.getControlPlaneInfo,
},
} {
k8sInfo := toPin
if k8sInfo.Resource == k8shostsensor.ControlPlaneInfo && hasCloudProvider {
// we retrieve control plane info only if we are not using a cloud provider
continue
}
kcData, err := k8sInfo.Query(ctx)
if err != nil {
addInfoToMap(k8sInfo.Resource, infoMap, err)
logger.L().Ctx(ctx).Warning("Failed to get resource from CRD",
helpers.String("resource", k8sInfo.Resource.String()),
helpers.Error(err))
}
if k8sInfo.Resource == k8shostsensor.CloudProviderInfo {
hasCloudProvider = hasCloudProviderInfo(kcData)
}
if len(kcData) > 0 {
res = append(res, kcData...)
}
}
logger.L().Debug("Done collecting information from CRDs", helpers.Int("totalResources", len(res)))
return res, infoMap, nil
}

View File

@@ -1,122 +0,0 @@
package hostsensorutils
import (
"context"
"fmt"
"github.com/kubescape/go-logger"
"github.com/kubescape/go-logger/helpers"
"github.com/kubescape/k8s-interface/k8sinterface"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
)
const (
// Host data CRD API group and version
hostDataGroup = "hostdata.kubescape.cloud"
hostDataVersion = "v1beta1"
)
// HostSensorHandler is a client that reads host sensor data from Kubernetes CRDs.
//
// The CRDs are created by the node-agent daemonset running on each node.
type HostSensorHandler struct {
k8sObj *k8sinterface.KubernetesApi
dynamicClient dynamic.Interface
}
// NewHostSensorHandler builds a new CRD-based host sensor handler.
func NewHostSensorHandler(k8sObj *k8sinterface.KubernetesApi, _ string) (*HostSensorHandler, error) {
if k8sObj == nil {
return nil, fmt.Errorf("nil k8s interface received")
}
config := k8sinterface.GetK8sConfig()
if config == nil {
return nil, fmt.Errorf("failed to get k8s config")
}
// force GRPC
config.AcceptContentTypes = "application/vnd.kubernetes.protobuf"
config.ContentType = "application/vnd.kubernetes.protobuf"
// Create dynamic client for CRD access
dynamicClient, err := dynamic.NewForConfig(config)
if err != nil {
return nil, fmt.Errorf("failed to create dynamic client: %w", err)
}
hsh := &HostSensorHandler{
k8sObj: k8sObj,
dynamicClient: dynamicClient,
}
// Verify we can access nodes (basic sanity check)
if nodeList, err := k8sObj.KubernetesClient.CoreV1().Nodes().List(k8sObj.Context, metav1.ListOptions{}); err != nil || len(nodeList.Items) == 0 {
if err == nil {
err = fmt.Errorf("no nodes to scan")
}
return hsh, fmt.Errorf("in NewHostSensorHandler, failed to get nodes list: %v", err)
}
return hsh, nil
}
// Init is a no-op for CRD-based implementation.
// The node-agent daemonset is expected to be already deployed and creating CRDs.
func (hsh *HostSensorHandler) Init(ctx context.Context) error {
logger.L().Info("Using CRD-based host sensor data collection (no deployment needed)")
// Verify that at least one CRD type exists
gvr := schema.GroupVersionResource{
Group: hostDataGroup,
Version: hostDataVersion,
Resource: "osreleasefiles",
}
list, err := hsh.dynamicClient.Resource(gvr).List(ctx, metav1.ListOptions{Limit: 1})
if err != nil {
logger.L().Warning("node-agent status: Failed to list OsReleaseFile CRDs - node-agent may not be deployed",
helpers.Error(err))
return fmt.Errorf("failed to verify CRD access: %w (ensure node-agent is deployed)", err)
}
if len(list.Items) == 0 {
logger.L().Warning("node-agent status: No OsReleaseFile CRDs found - node-agent may not be running or sensing yet")
} else {
logger.L().Info("node-agent status: Successfully verified CRD access", helpers.Int("osReleaseFiles", len(list.Items)))
}
return nil
}
// TearDown is a no-op for CRD-based implementation.
// CRDs are managed by the node-agent daemonset lifecycle.
func (hsh *HostSensorHandler) TearDown() error {
logger.L().Debug("CRD-based host sensor teardown (no-op)")
return nil
}
// listCRDResources is a generic function to list CRD resources and convert them to the expected format.
func (hsh *HostSensorHandler) listCRDResources(ctx context.Context, resourceName, kind string) ([]unstructured.Unstructured, error) {
gvr := schema.GroupVersionResource{
Group: hostDataGroup,
Version: hostDataVersion,
Resource: resourceName,
}
logger.L().Debug("Listing CRD resources",
helpers.String("resource", resourceName),
helpers.String("kind", kind))
list, err := hsh.dynamicClient.Resource(gvr).List(ctx, metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("failed to list %s CRDs: %w", kind, err)
}
logger.L().Debug("Retrieved CRD resources",
helpers.String("kind", kind),
helpers.Int("count", len(list.Items)))
return list.Items, nil
}

View File

@@ -0,0 +1,457 @@
package hostsensorutils
import (
"context"
_ "embed"
"fmt"
"os"
"sync"
"time"
"github.com/kubescape/go-logger"
"github.com/kubescape/go-logger/helpers"
"github.com/kubescape/k8s-interface/k8sinterface"
"github.com/kubescape/k8s-interface/workloadinterface"
"github.com/kubescape/kubescape/v3/core/cautils"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/watch"
)
var (
//go:embed hostsensor.yaml
hostSensorYAML string
namespaceWasPresent bool
)
const portName string = "scanner"
// HostSensorHandler is a client that interacts with a host-scanner component deployed on nodes.
//
// The API exposed by the host sensor is defined here: https://github.com/kubescape/host-scanner
type HostSensorHandler struct {
hostSensorPort int32
hostSensorPodNames map[string]string //map from pod names to node names
hostSensorUnscheduledPodNames map[string]string //map from pod names to node names
k8sObj *k8sinterface.KubernetesApi
daemonSet *appsv1.DaemonSet
podListLock sync.RWMutex
gracePeriod int64
workerPool workerPool
}
// NewHostSensorHandler builds a new http client to the host-scanner API.
func NewHostSensorHandler(k8sObj *k8sinterface.KubernetesApi, hostSensorYAMLFile string) (*HostSensorHandler, error) {
if k8sObj == nil {
return nil, fmt.Errorf("nil k8s interface received")
}
if hostSensorYAMLFile != "" {
d, err := loadHostSensorFromFile(hostSensorYAMLFile)
if err != nil {
return nil, fmt.Errorf("failed to load host-scanner yaml file, reason: %w", err)
}
hostSensorYAML = d
}
hsh := &HostSensorHandler{
k8sObj: k8sObj,
hostSensorPodNames: map[string]string{},
hostSensorUnscheduledPodNames: map[string]string{},
gracePeriod: int64(15),
workerPool: newWorkerPool(),
}
// Don't deploy on a cluster with no nodes. Some cloud providers prevent the termination of K8s objects for cluster with no nodes!!!
if nodeList, err := k8sObj.KubernetesClient.CoreV1().Nodes().List(k8sObj.Context, metav1.ListOptions{}); err != nil || len(nodeList.Items) == 0 {
if err == nil {
err = fmt.Errorf("no nodes to scan")
}
return hsh, fmt.Errorf("in NewHostSensorHandler, failed to get nodes list: %v", err)
}
return hsh, nil
}
// Init deploys the host-scanner and start watching the pods on the host.
func (hsh *HostSensorHandler) Init(ctx context.Context) error {
// deploy the YAML
// store namespace + port
// store pod names
// make sure all pods are running, after X seconds treat has running anyway, and log an error on the pods not running yet
logger.L().Info("Installing host scanner")
// log is used to avoid log duplication
// coming from the different host-scanner instances
log := NewLogCoupling()
cautils.StartSpinner()
defer cautils.StopSpinner()
if err := hsh.applyYAML(ctx); err != nil {
return fmt.Errorf("failed to apply host scanner YAML, reason: %v", err)
}
hsh.populatePodNamesToNodeNames(ctx, log)
if err := hsh.checkPodForEachNode(); err != nil {
logger.L().Ctx(ctx).Warning(failedToValidateHostSensorPodStatus, helpers.Error(err))
}
return nil
}
// checkNamespaceWasPresent check if the given namespace was already present on kubernetes and in "Active" state.
// Return true in case it find the namespace on the list, false otherwise.
// In case we have some error with the kubernetes APIs, it returns an error.
func (hsh *HostSensorHandler) checkNamespaceWasPresent(namespace string) bool {
ns, err := hsh.k8sObj.KubernetesClient.
CoreV1().
Namespaces().
Get(hsh.k8sObj.Context, namespace, metav1.GetOptions{})
if err != nil {
return false
}
// check also if it is in "Active" state.
if ns.Status.Phase != corev1.NamespaceActive {
return false
}
return true
}
// namespaceWasPresent return the namespaceWasPresent variable value.
func (hsh *HostSensorHandler) namespaceWasPresent() bool {
return namespaceWasPresent
}
func (hsh *HostSensorHandler) applyYAML(ctx context.Context) error {
workloads, err := cautils.ReadFile([]byte(hostSensorYAML), cautils.YAML_FILE_FORMAT)
if err != nil {
return fmt.Errorf("failed to read YAML files, reason: %v", err)
}
// Get namespace name
namespaceName := cautils.GetConfigMapNamespace()
for i := range workloads {
if workloads[i].GetKind() == "Namespace" {
namespaceName = workloads[i].GetName()
break
}
}
// check if namespace was already present on kubernetes
namespaceWasPresent = hsh.checkNamespaceWasPresent(namespaceName)
// Update workload data before applying
for i := range workloads {
w := workloadinterface.NewWorkloadObj(workloads[i].GetObject())
if w == nil {
return fmt.Errorf("invalid workload: %v", workloads[i].GetObject())
}
// set namespace in all objects
if w.GetKind() != "Namespace" {
logger.L().Debug("Setting namespace", helpers.String("kind", w.GetKind()), helpers.String("name", w.GetName()), helpers.String("namespace", namespaceName))
w.SetNamespace(namespaceName)
}
// Get container port
if w.GetKind() == "DaemonSet" {
containers, err := w.GetContainers()
if err != nil {
if erra := hsh.tearDownNamespace(namespaceName); erra != nil {
logger.L().Ctx(ctx).Warning(failedToTeardownNamespace, helpers.Error(erra))
}
return fmt.Errorf("container not found in DaemonSet: %v", err)
}
for j := range containers {
for k := range containers[j].Ports {
if containers[j].Ports[k].Name == portName {
hsh.hostSensorPort = containers[j].Ports[k].ContainerPort
}
}
}
}
// Apply workload
var newWorkload k8sinterface.IWorkload
var e error
if g, err := hsh.k8sObj.GetWorkload(w.GetNamespace(), w.GetKind(), w.GetName()); err == nil && g != nil {
newWorkload, e = hsh.k8sObj.UpdateWorkload(w)
} else {
newWorkload, e = hsh.k8sObj.CreateWorkload(w)
}
if e != nil {
if erra := hsh.tearDownNamespace(namespaceName); erra != nil {
logger.L().Ctx(ctx).Warning(failedToTeardownNamespace, helpers.Error(erra))
}
return fmt.Errorf("failed to create/update YAML, reason: %v", e)
}
// Save DaemonSet
if newWorkload.GetKind() == "DaemonSet" {
b, err := json.Marshal(newWorkload.GetObject())
if err != nil {
if erra := hsh.tearDownNamespace(namespaceName); erra != nil {
logger.L().Ctx(ctx).Warning(failedToTeardownNamespace, helpers.Error(erra))
}
return fmt.Errorf("failed to Marshal YAML of DaemonSet, reason: %v", err)
}
var ds appsv1.DaemonSet
if err := json.Unmarshal(b, &ds); err != nil {
if erra := hsh.tearDownNamespace(namespaceName); erra != nil {
logger.L().Ctx(ctx).Warning(failedToTeardownNamespace, helpers.Error(erra))
}
return fmt.Errorf("failed to Unmarshal YAML of DaemonSet, reason: %v", err)
}
hsh.daemonSet = &ds
}
}
return nil
}
func (hsh *HostSensorHandler) checkPodForEachNode() error {
deadline := time.Now().Add(time.Second * 100)
for {
nodesList, err := hsh.k8sObj.KubernetesClient.CoreV1().Nodes().List(hsh.k8sObj.Context, metav1.ListOptions{})
if err != nil {
return fmt.Errorf("in checkPodsForEveryNode, failed to get nodes list: %v", nodesList)
}
hsh.podListLock.RLock()
podsNum := len(hsh.hostSensorPodNames)
unschedPodNum := len(hsh.hostSensorUnscheduledPodNames)
hsh.podListLock.RUnlock()
if len(nodesList.Items) <= podsNum+unschedPodNum {
break
}
if time.Now().After(deadline) {
hsh.podListLock.RLock()
podsMap := hsh.hostSensorPodNames
hsh.podListLock.RUnlock()
return fmt.Errorf("host-scanner pods number (%d) differ than nodes number (%d) after deadline exceeded. Kubescape will take data only from the pods below: %v",
podsNum, len(nodesList.Items), podsMap)
}
time.Sleep(100 * time.Millisecond)
}
return nil
}
// initiating routine to keep pod list updated
func (hsh *HostSensorHandler) populatePodNamesToNodeNames(ctx context.Context, log *LogsMap) {
go func() {
var watchRes watch.Interface
var err error
watchRes, err = hsh.k8sObj.KubernetesClient.CoreV1().Pods(hsh.daemonSet.Namespace).Watch(hsh.k8sObj.Context, metav1.ListOptions{
Watch: true,
LabelSelector: fmt.Sprintf("name=%s", hsh.daemonSet.Spec.Template.Labels["name"]),
})
if err != nil {
logger.L().Ctx(ctx).Warning(failedToWatchOverDaemonSetPods, helpers.Error(err))
}
if watchRes == nil {
logger.L().Ctx(ctx).Error("failed to watch over DaemonSet pods, will not be able to get host-scanner data")
return
}
for eve := range watchRes.ResultChan() {
pod, ok := eve.Object.(*corev1.Pod)
if !ok {
continue
}
go hsh.updatePodInListAtomic(ctx, eve.Type, pod, log)
}
}()
}
func (hsh *HostSensorHandler) updatePodInListAtomic(ctx context.Context, eventType watch.EventType, podObj *corev1.Pod, log *LogsMap) {
hsh.podListLock.Lock()
defer hsh.podListLock.Unlock()
switch eventType {
case watch.Added, watch.Modified:
if podObj.Status.Phase == corev1.PodRunning && len(podObj.Status.ContainerStatuses) > 0 &&
podObj.Status.ContainerStatuses[0].Ready {
hsh.hostSensorPodNames[podObj.ObjectMeta.Name] = podObj.Spec.NodeName
delete(hsh.hostSensorUnscheduledPodNames, podObj.ObjectMeta.Name)
} else {
if podObj.Status.Phase == corev1.PodPending && len(podObj.Status.Conditions) > 0 &&
podObj.Status.Conditions[0].Reason == corev1.PodReasonUnschedulable {
nodeName := ""
if podObj.Spec.Affinity != nil && podObj.Spec.Affinity.NodeAffinity != nil &&
podObj.Spec.Affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution != nil &&
len(podObj.Spec.Affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms) > 0 &&
len(podObj.Spec.Affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms[0].MatchFields) > 0 &&
len(podObj.Spec.Affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms[0].MatchFields[0].Values) > 0 {
nodeName = podObj.Spec.Affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms[0].MatchFields[0].Values[0]
}
if !log.isDuplicated(oneHostSensorPodIsUnabledToSchedule) {
logger.L().Ctx(ctx).Warning(oneHostSensorPodIsUnabledToSchedule,
helpers.String("message", podObj.Status.Conditions[0].Message))
log.update(oneHostSensorPodIsUnabledToSchedule)
}
if nodeName != "" {
hsh.hostSensorUnscheduledPodNames[podObj.ObjectMeta.Name] = nodeName
}
} else {
delete(hsh.hostSensorPodNames, podObj.ObjectMeta.Name)
}
}
default:
delete(hsh.hostSensorPodNames, podObj.ObjectMeta.Name)
}
}
// tearDownHostScanner manage the host-scanner deletion.
func (hsh *HostSensorHandler) tearDownHostScanner(namespace string) error {
client := hsh.k8sObj.KubernetesClient
// delete host-scanner DaemonSet
err := client.AppsV1().
DaemonSets(namespace).
Delete(
hsh.k8sObj.Context,
hsh.daemonSet.Name,
metav1.DeleteOptions{
GracePeriodSeconds: &hsh.gracePeriod,
},
)
if err != nil {
return fmt.Errorf("failed to delete host-scanner DaemonSet: %v", err)
}
// wait for DaemonSet to be deleted
err = hsh.waitHostScannerDeleted(hsh.k8sObj.Context)
if err != nil {
return fmt.Errorf("failed to delete host-scanner DaemonSet: %v", err)
}
return nil
}
// tearDownNamespace manage the given namespace deletion.
// At first, it checks if the namespace was already present before installing host-scanner.
// In that case skips the deletion.
// If was not, then patches the namespace in order to remove the finalizers,
// and finally delete the it.
func (hsh *HostSensorHandler) tearDownNamespace(namespace string) error {
// if namespace was already present on kubernetes (before installing host-scanner),
// then we shouldn't delete it.
if hsh.namespaceWasPresent() {
return nil
}
// to make it more readable we store the object client in a variable
client := hsh.k8sObj.KubernetesClient
// prepare patch json to remove finalizers from namespace
patchData := `
[
{
"op": "replace",
"path": "/metadata/finalizers",
"value": []
}
]
`
// patch namespace object removing finalizers
_, err := client.CoreV1().
Namespaces().
Patch(
hsh.k8sObj.Context,
namespace,
types.JSONPatchType,
[]byte(patchData),
metav1.PatchOptions{},
)
if err != nil {
return fmt.Errorf("failed to remove finalizers from Namespace: %v", err)
}
// patch namespace object removing finalizers
// delete namespace object
err = client.CoreV1().
Namespaces().
Delete(
hsh.k8sObj.Context,
namespace,
metav1.DeleteOptions{
GracePeriodSeconds: &hsh.gracePeriod,
},
)
if err != nil {
return fmt.Errorf("failed to delete %s Namespace: %v", namespace, err)
}
return nil
}
func (hsh *HostSensorHandler) TearDown() error {
namespace := hsh.GetNamespace()
// delete DaemonSet
if err := hsh.tearDownHostScanner(namespace); err != nil {
return fmt.Errorf("failed to delete host-scanner DaemonSet: %v", err)
}
// delete Namespace
if err := hsh.tearDownNamespace(namespace); err != nil {
return fmt.Errorf("failed to delete host-scanner Namespace: %v", err)
}
return nil
}
func (hsh *HostSensorHandler) GetNamespace() string {
if hsh.daemonSet == nil {
return ""
}
return hsh.daemonSet.Namespace
}
func loadHostSensorFromFile(hostSensorYAMLFile string) (string, error) {
dat, err := os.ReadFile(hostSensorYAMLFile)
if err != nil {
return "", err
}
if len(dat) == 0 {
return "", fmt.Errorf("empty file")
}
if !cautils.IsYaml(hostSensorYAMLFile) {
return "", fmt.Errorf("invalid file format")
}
return string(dat), err
}
// waitHostScannerDeleted watch for host-scanner deletion.
// In case it fails it returns an error.
func (hsh *HostSensorHandler) waitHostScannerDeleted(ctx context.Context) error {
labelSelector := fmt.Sprintf("name=%s", hsh.daemonSet.Name)
opts := metav1.ListOptions{
TypeMeta: metav1.TypeMeta{},
LabelSelector: labelSelector,
FieldSelector: "",
}
watcher, err := hsh.k8sObj.KubernetesClient.CoreV1().
Pods(hsh.daemonSet.Namespace).
Watch(ctx, opts)
if err != nil {
return err
}
defer watcher.Stop()
for {
select {
case event := <-watcher.ResultChan():
if event.Type == watch.Deleted {
return nil
}
case <-ctx.Done():
return nil
}
}
}

View File

@@ -0,0 +1,232 @@
package hostsensorutils
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/kubescape/kubescape/v3/internal/testutils"
"github.com/kubescape/opa-utils/objectsenvelopes/hostsensor"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestHostSensorHandler(t *testing.T) {
t.Parallel()
ctx := context.Background()
t.Run("with default manifest", func(t *testing.T) {
t.Run("should build host sensor", func(t *testing.T) {
k8s := NewKubernetesApiMock(WithNode(mockNode1()), WithPod(mockPod1()), WithPod(mockPod2()), WithResponses(mockResponses()))
h, err := NewHostSensorHandler(k8s, "")
require.NoError(t, err)
require.NotNil(t, h)
t.Run("should initialize host sensor", func(t *testing.T) {
require.NoError(t, h.Init(ctx))
w, err := k8s.KubernetesClient.CoreV1().Pods(h.daemonSet.Namespace).Watch(ctx, metav1.ListOptions{})
require.NoError(t, err)
w.Stop()
require.Len(t, h.hostSensorPodNames, 2)
})
t.Run("should return namespace", func(t *testing.T) {
require.Equal(t, "kubescape", h.GetNamespace())
})
t.Run("should collect resources from pods - happy path", func(t *testing.T) {
envelope, status, err := h.CollectResources(ctx)
require.NoError(t, err)
require.Len(t, envelope, 9*2) // has cloud provider, no control plane requested
require.Len(t, status, 0)
foundControl, foundProvider := false, false
for _, sensed := range envelope {
if sensed.Kind == ControlPlaneInfo.String() {
foundControl = true
}
if sensed.Kind == CloudProviderInfo.String() {
foundProvider = hasCloudProviderInfo([]hostsensor.HostSensorDataEnvelope{sensed})
}
}
require.False(t, foundControl)
require.True(t, foundProvider)
})
})
t.Run("should build host sensor without cloud provider", func(t *testing.T) {
k8s := NewKubernetesApiMock(WithNode(mockNode1()), WithPod(mockPod1()), WithPod(mockPod2()), WithResponses(mockResponsesNoCloudProvider()))
h, err := NewHostSensorHandler(k8s, "")
require.NoError(t, err)
require.NotNil(t, h)
t.Run("should initialize host sensor", func(t *testing.T) {
require.NoError(t, h.Init(ctx))
w, err := k8s.KubernetesClient.CoreV1().Pods(h.daemonSet.Namespace).Watch(ctx, metav1.ListOptions{})
require.NoError(t, err)
w.Stop()
require.Len(t, h.hostSensorPodNames, 2)
})
t.Run("should get version", func(t *testing.T) {
version, err := h.getVersion()
require.NoError(t, err)
require.Equal(t, "v1.0.45", version)
})
t.Run("ForwardToPod is a stub, not implemented", func(t *testing.T) {
resp, err := h.forwardToPod("pod1", "/version")
require.Contains(t, err.Error(), "not implemented")
require.Nil(t, resp)
})
t.Run("should collect resources from pods", func(t *testing.T) {
envelope, status, err := h.CollectResources(ctx)
require.NoError(t, err)
require.Len(t, envelope, 10*2) // has empty cloud provider, has control plane info
require.Len(t, status, 0)
foundControl, foundProvider := false, false
for _, sensed := range envelope {
if sensed.Kind == ControlPlaneInfo.String() {
foundControl = true
}
if sensed.Kind == CloudProviderInfo.String() {
foundProvider = hasCloudProviderInfo([]hostsensor.HostSensorDataEnvelope{sensed})
}
}
require.True(t, foundControl)
require.False(t, foundProvider)
})
})
t.Run("should build host sensor with error in response from /version", func(t *testing.T) {
k8s := NewKubernetesApiMock(WithNode(mockNode1()),
WithPod(mockPod1()),
WithPod(mockPod2()),
WithResponses(mockResponsesNoCloudProvider()),
WithErrorResponse(RestURL{"http", "pod1", "7888", "/version"}), // this endpoint will return an error from this pod
WithErrorResponse(RestURL{"http", "pod2", "7888", "/version"}), // this endpoint will return an error from this pod
)
h, err := NewHostSensorHandler(k8s, "")
require.NoError(t, err)
require.NotNil(t, h)
t.Run("should initialize host sensor", func(t *testing.T) {
require.NoError(t, h.Init(ctx))
w, err := k8s.KubernetesClient.CoreV1().Pods(h.daemonSet.Namespace).Watch(ctx, metav1.ListOptions{})
require.NoError(t, err)
w.Stop()
require.Len(t, h.hostSensorPodNames, 2)
})
t.Run("should NOT be able to get version", func(t *testing.T) {
// NOTE: GetVersion might be successful if only one pod responds successfully.
// In order to ensure an error, we need ALL pods to error.
_, err := h.getVersion()
require.Error(t, err)
require.Contains(t, err.Error(), "mock")
})
})
t.Run("should FAIL to build host sensor because there are no nodes", func(t *testing.T) {
h, err := NewHostSensorHandler(NewKubernetesApiMock(), "")
require.Error(t, err)
require.NotNil(t, h)
require.Contains(t, err.Error(), "no nodes to scan")
})
})
t.Run("should NOT build host sensor with nil k8s API", func(t *testing.T) {
h, err := NewHostSensorHandler(nil, "")
require.Error(t, err)
require.Nil(t, h)
})
t.Run("with manifest from YAML file", func(t *testing.T) {
t.Run("should build host sensor", func(t *testing.T) {
k8s := NewKubernetesApiMock(WithNode(mockNode1()), WithPod(mockPod1()), WithPod(mockPod2()), WithResponses(mockResponses()))
h, err := NewHostSensorHandler(k8s, filepath.Join(testutils.CurrentDir(), "hostsensor.yaml"))
require.NoError(t, err)
require.NotNil(t, h)
t.Run("should initialize host sensor", func(t *testing.T) {
require.NoError(t, h.Init(ctx))
w, err := k8s.KubernetesClient.CoreV1().Pods(h.daemonSet.Namespace).Watch(ctx, metav1.ListOptions{})
require.NoError(t, err)
w.Stop()
require.Len(t, h.hostSensorPodNames, 2)
})
})
})
t.Run("with manifest from invalid YAML file", func(t *testing.T) {
t.Run("should NOT build host sensor", func(t *testing.T) {
var invalid string
t.Run("should create temp file", func(t *testing.T) {
file, err := os.CreateTemp("", "*.yaml")
require.NoError(t, err)
t.Cleanup(func() {
_ = os.Remove(file.Name())
})
_, err = file.Write([]byte(" x: 1"))
require.NoError(t, err)
invalid = file.Name()
require.NoError(t, file.Close())
})
k8s := NewKubernetesApiMock(WithNode(mockNode1()), WithPod(mockPod1()), WithPod(mockPod2()), WithResponses(mockResponses()))
_, err := NewHostSensorHandler(k8s, filepath.Join(testutils.CurrentDir(), invalid))
require.Error(t, err)
})
})
// TODO(test coverage): the following cases are not covered by tests yet.
//
// * applyYAML fails
// * checkPodForEachNode fails, or times out
// * non-active namespace
// * getPodList fails when GetVersion
// * getPodList fails when CollectResources
// * error cases that trigger a namespace tear-down
// * watch pods with a Delete event
// * explicit TearDown()
//
// Notice that the package doesn't current pass tests with the race detector enabled.
}
func TestLoadHostSensorFromFile_NoError(t *testing.T) {
content, err := loadHostSensorFromFile("testdata/hostsensor.yaml")
assert.NotEqual(t, "", content)
assert.Nil(t, err)
}
func TestLoadHostSensorFromFile_Error(t *testing.T) {
content, err := loadHostSensorFromFile("testdata/hostsensor_invalid.yaml")
assert.Equal(t, "", content)
assert.NotNil(t, err)
content, err = loadHostSensorFromFile("testdata/empty_hostsensor.yaml")
assert.Equal(t, "", content)
assert.NotNil(t, err)
content, err = loadHostSensorFromFile("testdata/notAYamlFile.txt")
assert.Equal(t, "", content)
assert.NotNil(t, err)
}

View File

@@ -0,0 +1,293 @@
package hostsensorutils
import (
"context"
stdjson "encoding/json"
"errors"
"fmt"
"reflect"
"strings"
"sync"
"github.com/kubescape/go-logger"
"github.com/kubescape/k8s-interface/k8sinterface"
"github.com/kubescape/opa-utils/objectsenvelopes/hostsensor"
"github.com/kubescape/opa-utils/reporthandling/apis"
)
// getPodList clones the internal list of pods being watched as a map of pod names.
func (hsh *HostSensorHandler) getPodList() map[string]string {
hsh.podListLock.RLock()
res := make(map[string]string, len(hsh.hostSensorPodNames))
for k, v := range hsh.hostSensorPodNames {
res[k] = v
}
hsh.podListLock.RUnlock()
return res
}
// httpGetToPod sends the request to a pod using the HostSensorPort.
func (hsh *HostSensorHandler) httpGetToPod(podName, path string) ([]byte, error) {
restProxy := hsh.k8sObj.KubernetesClient.CoreV1().Pods(hsh.daemonSet.Namespace).ProxyGet("http", podName, fmt.Sprintf("%d", hsh.hostSensorPort), path, map[string]string{})
return restProxy.DoRaw(hsh.k8sObj.Context)
}
func (hsh *HostSensorHandler) getResourcesFromPod(podName, nodeName string, resourceKind scannerResource, path string) (hostsensor.HostSensorDataEnvelope, error) {
// send the request and pack the response as an hostSensorDataEnvelope
resBytes, err := hsh.httpGetToPod(podName, path)
if err != nil {
return hostsensor.HostSensorDataEnvelope{}, err
}
hostSensorDataEnvelope := hostsensor.HostSensorDataEnvelope{}
hostSensorDataEnvelope.SetApiVersion(k8sinterface.JoinGroupVersion(hostsensor.GroupHostSensor, hostsensor.Version))
hostSensorDataEnvelope.SetKind(resourceKind.String())
hostSensorDataEnvelope.SetName(nodeName)
hostSensorDataEnvelope.SetData(resBytes)
return hostSensorDataEnvelope, nil
}
// forwardToPod is currently not implemented.
func (hsh *HostSensorHandler) forwardToPod(podName, path string) ([]byte, error) {
// NOT IN USE:
// ---
// spawn port forwarding
// req := hsh.k8sObj.KubernetesClient.CoreV1().RESTClient().Post()
// req = req.Name(podName)
// req = req.Namespace(hsh.DaemonSet.Namespace)
// req = req.Resource("pods")
// req = req.SubResource("portforward")
// ----
// https://github.com/gianarb/kube-port-forward
// fullPath := fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/portforward",
// hsh.DaemonSet.Namespace, podName)
// transport, upgrader, err := spdy.RoundTripperFor(hsh.k8sObj.KubernetesClient.config)
// if err != nil {
// return nil, err
// }
// hostIP := strings.TrimLeft(req.RestConfig.Host, "htps:/")
// dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, http.MethodPost, &url.URL{Scheme: "http", Path: path, Host: hostIP})
return nil, errors.New("not implemented")
}
// sendAllPodsHTTPGETRequest fills the raw bytes response in the envelope and the node name, but not the GroupVersionKind
// so the caller is responsible to convert the raw data to some structured data and add the GroupVersionKind details
//
// The function produces a worker-pool with a fixed number of workers.
//
// For each node the request is pushed to the jobs channel, the worker sends the request and pushes the result to the result channel.
// When all workers have finished, the function returns a list of results
func (hsh *HostSensorHandler) sendAllPodsHTTPGETRequest(ctx context.Context, path string, requestKind scannerResource) ([]hostsensor.HostSensorDataEnvelope, error) {
podList := hsh.getPodList()
res := make([]hostsensor.HostSensorDataEnvelope, 0, len(podList))
var wg sync.WaitGroup
// initialization of the channels
hsh.workerPool.init(len(podList))
// log is used to avoid log duplication
// coming from the different host-scanner instances
log := NewLogCoupling()
hsh.workerPool.hostSensorApplyJobs(podList, path, requestKind)
hsh.workerPool.hostSensorGetResults(&res)
hsh.workerPool.createWorkerPool(ctx, hsh, &wg, log)
hsh.workerPool.waitForDone(&wg)
return res, nil
}
// getVersion returns the version of the deployed host scanner.
//
// NOTE: we pick the version from the first responding pod.
func (hsh *HostSensorHandler) getVersion() (string, error) {
// loop over pods and port-forward it to each of them
podList := hsh.getPodList()
// initialization of the channels
hsh.workerPool.init(len(podList))
hsh.workerPool.hostSensorApplyJobs(podList, "/version", "version")
for job := range hsh.workerPool.jobs {
resBytes, err := hsh.httpGetToPod(job.podName, job.path)
if err != nil {
return "", err
} else {
version := strings.ReplaceAll(string(resBytes), "\"", "")
version = strings.ReplaceAll(version, "\n", "")
return version, nil
}
}
return "", nil
}
// getKernelVariables returns the list of Linux Kernel variables.
func (hsh *HostSensorHandler) getKernelVariables(ctx context.Context) ([]hostsensor.HostSensorDataEnvelope, error) {
// loop over pods and port-forward it to each of them
return hsh.sendAllPodsHTTPGETRequest(ctx, "/LinuxKernelVariables", LinuxKernelVariables)
}
// getOpenPortsList returns the list of open ports.
func (hsh *HostSensorHandler) getOpenPortsList(ctx context.Context) ([]hostsensor.HostSensorDataEnvelope, error) {
// loop over pods and port-forward it to each of them
return hsh.sendAllPodsHTTPGETRequest(ctx, "/openedPorts", OpenPortsList)
}
// getLinuxSecurityHardeningStatus returns the list of LinuxSecurityHardeningStatus metadata.
func (hsh *HostSensorHandler) getLinuxSecurityHardeningStatus(ctx context.Context) ([]hostsensor.HostSensorDataEnvelope, error) {
// loop over pods and port-forward it to each of them
return hsh.sendAllPodsHTTPGETRequest(ctx, "/linuxSecurityHardening", LinuxSecurityHardeningStatus)
}
// getKubeletInfo returns the list of kubelet metadata.
func (hsh *HostSensorHandler) getKubeletInfo(ctx context.Context) ([]hostsensor.HostSensorDataEnvelope, error) {
// loop over pods and port-forward it to each of them
return hsh.sendAllPodsHTTPGETRequest(ctx, "/kubeletInfo", KubeletInfo)
}
// getKubeProxyInfo returns the list of kubeProxy metadata.
func (hsh *HostSensorHandler) getKubeProxyInfo(ctx context.Context) ([]hostsensor.HostSensorDataEnvelope, error) {
// loop over pods and port-forward it to each of them
return hsh.sendAllPodsHTTPGETRequest(ctx, "/kubeProxyInfo", KubeProxyInfo)
}
// getControlPlaneInfo returns the list of controlPlaneInfo metadata
func (hsh *HostSensorHandler) getControlPlaneInfo(ctx context.Context) ([]hostsensor.HostSensorDataEnvelope, error) {
// loop over pods and port-forward it to each of them
return hsh.sendAllPodsHTTPGETRequest(ctx, "/controlPlaneInfo", ControlPlaneInfo)
}
// getCloudProviderInfo returns the list of cloudProviderInfo metadata.
func (hsh *HostSensorHandler) getCloudProviderInfo(ctx context.Context) ([]hostsensor.HostSensorDataEnvelope, error) {
// loop over pods and port-forward it to each of them
return hsh.sendAllPodsHTTPGETRequest(ctx, "/cloudProviderInfo", CloudProviderInfo)
}
// getCNIInfo returns the list of CNI metadata
func (hsh *HostSensorHandler) getCNIInfo(ctx context.Context) ([]hostsensor.HostSensorDataEnvelope, error) {
// loop over pods and port-forward it to each of them
return hsh.sendAllPodsHTTPGETRequest(ctx, "/CNIInfo", CNIInfo)
}
// getKernelVersion returns the list of kernelVersion metadata.
func (hsh *HostSensorHandler) getKernelVersion(ctx context.Context) ([]hostsensor.HostSensorDataEnvelope, error) {
// loop over pods and port-forward it to each of them
return hsh.sendAllPodsHTTPGETRequest(ctx, "/kernelVersion", "KernelVersion")
}
// getOsReleaseFile returns the list of osRelease metadata.
func (hsh *HostSensorHandler) getOsReleaseFile(ctx context.Context) ([]hostsensor.HostSensorDataEnvelope, error) {
// loop over pods and port-forward it to each of them
return hsh.sendAllPodsHTTPGETRequest(ctx, "/osRelease", "OsReleaseFile")
}
// hasCloudProviderInfo iterates over the []hostsensor.HostSensorDataEnvelope list to find info about the cloud provider.
//
// If information are found, then return true. Return false otherwise.
func hasCloudProviderInfo(cpi []hostsensor.HostSensorDataEnvelope) bool {
for index := range cpi {
if !reflect.DeepEqual(cpi[index].GetData(), stdjson.RawMessage("{}\n")) {
return true
}
}
return false
}
// CollectResources collects all required information about all the pods for this host.
func (hsh *HostSensorHandler) CollectResources(ctx context.Context) ([]hostsensor.HostSensorDataEnvelope, map[string]apis.StatusInfo, error) {
res := make([]hostsensor.HostSensorDataEnvelope, 0)
infoMap := make(map[string]apis.StatusInfo)
if hsh.daemonSet == nil {
return res, nil, nil
}
logger.L().Debug("Accessing host scanner")
version, err := hsh.getVersion()
if err != nil {
logger.L().Ctx(ctx).Warning(err.Error())
}
if len(version) > 0 {
logger.L().Info("Host scanner version : " + version)
} else {
logger.L().Info("Unknown host scanner version")
}
var hasCloudProvider bool
for _, toPin := range []struct {
Resource scannerResource
Query func(context.Context) ([]hostsensor.HostSensorDataEnvelope, error)
}{
// queries to the deployed host-scanner
{
Resource: OsReleaseFile,
Query: hsh.getOsReleaseFile,
},
{
Resource: KernelVersion,
Query: hsh.getKernelVersion,
},
{
Resource: LinuxSecurityHardeningStatus,
Query: hsh.getLinuxSecurityHardeningStatus,
},
{
Resource: OpenPortsList,
Query: hsh.getOpenPortsList,
},
{
Resource: LinuxKernelVariables,
Query: hsh.getKernelVariables,
},
{
Resource: KubeletInfo,
Query: hsh.getKubeletInfo,
},
{
Resource: KubeProxyInfo,
Query: hsh.getKubeProxyInfo,
},
{
Resource: CloudProviderInfo,
Query: hsh.getCloudProviderInfo,
},
{
Resource: CNIInfo,
Query: hsh.getCNIInfo,
},
{
// ControlPlaneInfo is queried _after_ CloudProviderInfo.
Resource: ControlPlaneInfo,
Query: hsh.getControlPlaneInfo,
},
} {
k8sInfo := toPin
if k8sInfo.Resource == ControlPlaneInfo && hasCloudProvider {
// we retrieve control plane info only if we are not using a cloud provider
continue
}
kcData, err := k8sInfo.Query(ctx)
if err != nil {
addInfoToMap(k8sInfo.Resource, infoMap, err)
logger.L().Ctx(ctx).Warning(err.Error())
}
if k8sInfo.Resource == CloudProviderInfo {
hasCloudProvider = hasCloudProviderInfo(kcData)
}
if len(kcData) > 0 {
res = append(res, kcData...)
}
}
logger.L().Debug("Done reading information from host scanner")
return res, infoMap, nil
}

View File

@@ -11,4 +11,5 @@ type IHostSensor interface {
Init(ctx context.Context) error
TearDown() error
CollectResources(context.Context) ([]hostsensor.HostSensorDataEnvelope, map[string]apis.StatusInfo, error)
GetNamespace() string
}

View File

@@ -27,3 +27,7 @@ func (hshm *HostSensorHandlerMock) TearDown() error {
func (hshm *HostSensorHandlerMock) CollectResources(_ context.Context) ([]hostsensor.HostSensorDataEnvelope, map[string]apis.StatusInfo, error) {
return []hostsensor.HostSensorDataEnvelope{}, nil, nil
}
func (hshm *HostSensorHandlerMock) GetNamespace() string {
return ""
}

View File

@@ -0,0 +1,98 @@
package hostsensorutils
import (
"context"
"sync"
"github.com/kubescape/go-logger"
"github.com/kubescape/go-logger/helpers"
"github.com/kubescape/opa-utils/objectsenvelopes/hostsensor"
)
const noOfWorkers int = 10
type job struct {
podName string
nodeName string
requestKind scannerResource
path string
}
type workerPool struct {
jobs chan job
results chan hostsensor.HostSensorDataEnvelope
done chan bool
noOfWorkers int
}
func newWorkerPool() workerPool {
wp := workerPool{}
wp.noOfWorkers = noOfWorkers
wp.init()
return wp
}
func (wp *workerPool) init(noOfPods ...int) {
if len(noOfPods) > 0 && noOfPods[0] < noOfWorkers {
wp.noOfWorkers = noOfPods[0]
}
// init the channels
wp.jobs = make(chan job, noOfWorkers)
wp.results = make(chan hostsensor.HostSensorDataEnvelope, noOfWorkers)
wp.done = make(chan bool)
}
// The worker takes a job out of the chan, executes the request, and pushes the result to the results chan
func (wp *workerPool) hostSensorWorker(ctx context.Context, hsh *HostSensorHandler, wg *sync.WaitGroup, log *LogsMap) {
defer wg.Done()
for job := range wp.jobs {
hostSensorDataEnvelope, err := hsh.getResourcesFromPod(job.podName, job.nodeName, job.requestKind, job.path)
if err != nil && !log.isDuplicated(failedToGetData) {
logger.L().Ctx(ctx).Warning(failedToGetData, helpers.String("path", job.path), helpers.Error(err))
log.update(failedToGetData)
continue
}
wp.results <- hostSensorDataEnvelope
}
}
func (wp *workerPool) createWorkerPool(ctx context.Context, hsh *HostSensorHandler, wg *sync.WaitGroup, log *LogsMap) {
for i := 0; i < noOfWorkers; i++ {
wg.Add(1)
go wp.hostSensorWorker(ctx, hsh, wg, log)
}
}
func (wp *workerPool) waitForDone(wg *sync.WaitGroup) {
// Waiting for workers to finish
wg.Wait()
close(wp.results)
// Waiting for the results to be processed
<-wp.done
}
func (wp *workerPool) hostSensorGetResults(result *[]hostsensor.HostSensorDataEnvelope) {
go func() {
for res := range wp.results {
*result = append(*result, res)
}
wp.done <- true
}()
}
func (wp *workerPool) hostSensorApplyJobs(podList map[string]string, path string, requestKind scannerResource) {
go func() {
for podName, nodeName := range podList {
thisJob := job{
podName: podName,
nodeName: nodeName,
requestKind: requestKind,
path: path,
}
wp.jobs <- thisJob
}
close(wp.jobs)
}()
}

View File

@@ -0,0 +1,16 @@
package hostsensorutils
import (
"testing"
"github.com/stretchr/testify/assert"
)
// Initializes a workerPool struct with default values and returns it
func TestNewWorkerPoolDefaultValues(t *testing.T) {
wp := newWorkerPool()
assert.Equal(t, noOfWorkers, wp.noOfWorkers)
assert.NotNil(t, wp.jobs)
assert.NotNil(t, wp.results)
assert.NotNil(t, wp.done)
}

Some files were not shown because too many files have changed in this diff Show More