diff --git a/.github/workflows/claude-remediation.yml b/.github/workflows/claude-remediation.yml new file mode 100644 index 000000000..445e1b5d6 --- /dev/null +++ b/.github/workflows/claude-remediation.yml @@ -0,0 +1,76 @@ +name: Claude Remediation (suggest-only) + +on: + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: write + issues: write + +concurrency: + group: claude-remediation-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + remediate: + # Only PR comments (issue_comment fires for both issues and PRs) that + # contain the trigger phrase. This workflow never runs on a bare scan + # failure - a maintainer has to ask for it explicitly. + if: > + github.event.issue.pull_request != null && + (contains(github.event.comment.body, '/remediate') || + contains(github.event.comment.body, '/claude fix')) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Get PR head ref + id: pr + uses: actions/github-script@v7 + with: + script: | + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + }); + core.setOutput('sha', pr.head.sha); + + - name: Checkout PR (read-only) + uses: actions/checkout@v4 + with: + ref: ${{ steps.pr.outputs.sha }} + fetch-depth: 0 + + # `secrets` isn't a valid context in a job/step `if:`, so this + # produces a step output instead. That way the job skips cleanly + # (still a passing status) instead of failing every /remediate + # request until ANTHROPIC_API_KEY is actually configured. + - name: Check for Anthropic API key + id: check-key + run: echo "present=${{ secrets.ANTHROPIC_API_KEY != '' }}" >> "$GITHUB_OUTPUT" + + - name: Run Claude Code diagnosis + if: steps.check-key.outputs.present == 'true' + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} + trigger_phrase: "/remediate" + # Deliberately no Edit/Write/git-commit tools and no + # contents:write permission above: this workflow diagnoses and + # proposes a patch, it never writes to the branch. A human + # applies the suggested diff themselves. + claude_args: | + --allowedTools "Read,Grep,Glob,Bash(git diff:*),Bash(git log:*),Bash(gh pr diff:*),Bash(gh run view:*),Bash(gh api:*)" + prompt: | + A maintainer asked for remediation help on this PR via /remediate + or /claude fix. Diagnose the failing security/CI checks (Gitleaks, + Trivy, govulncheck, CodeQL, or test failures) using `gh run view` + and `gh api` against this PR's head commit. + + Produce the minimal fix as a unified diff and post it in a single + PR comment along with a short explanation of the root cause. + Do not modify any files and do not commit anything - the + maintainer will apply the patch by hand. diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml new file mode 100644 index 000000000..44005f119 --- /dev/null +++ b/.github/workflows/claude-review.yml @@ -0,0 +1,62 @@ +name: Claude PR Review + +on: + pull_request: + types: [opened, synchronize] + branches: [ "master" ] + +permissions: + contents: read + pull-requests: write + issues: write + +concurrency: + group: claude-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + review: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout PR + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + # `secrets` isn't a valid context in a job/step `if:`, so this + # produces a step output instead. That way the job skips cleanly + # (still a passing status) instead of failing every PR until + # ANTHROPIC_API_KEY is actually configured. + - name: Check for Anthropic API key + id: check-key + run: echo "present=${{ secrets.ANTHROPIC_API_KEY != '' }}" >> "$GITHUB_OUTPUT" + + - name: Run Claude Code review + if: steps.check-key.outputs.present == 'true' + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} + # Read-only tool set: this job diagnoses and comments, it never + # edits files or pushes commits. Auto-remediation is a separate, + # explicitly-triggered workflow (claude-remediation.yml). + claude_args: | + --allowedTools "Read,Grep,Glob,Bash(git diff:*),Bash(git log:*),Bash(gh pr diff:*)" + prompt: | + Review this pull request's diff against joltrin's security standards: + - No hardcoded secrets, credentials, or tokens. + - No new command/SQL/path injection surfaces. + - Containers and CI steps keep least-privilege defaults: non-root + execution, no unnecessary write scopes, pinned action/image + versions. + - Go: no unchecked errors on security-relevant paths (auth, crypto, + file access), no unsafe use of user input. + - Dependency changes: flag anything pulling in an unpinned or + unusually broad version range. + + Post findings as a single PR comment: a short summary, then any + issues ranked by severity with file:line references. If nothing + is found, say so briefly. Do not modify any files. diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..d891022ef --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,73 @@ +name: CodeQL + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + schedule: + - cron: '0 7 * * 1' + workflow_dispatch: + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + include: + - language: go + build-mode: manual + - language: javascript-typescript + build-mode: none + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + if: matrix.language == 'go' + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + queries: security-extended + + # demo and demo-agents are WASM-only (import syscall/js) and only + # compile under GOOS=js GOARCH=wasm, same exclusion ci.yml applies + # to vet/build/test. autobuild would fail on them, so build the + # native tree and the wasm packages as two separate passes. + - name: Build Go (native) + if: matrix.language == 'go' + shell: bash + run: go build $(go list ./... 2>/dev/null | grep -Ev '/demo$|/demo-agents$') + + # -o is required here: without it, `go build ./demo/...` under + # GOOS=js GOARCH=wasm writes a binary named after the package + # directory into the cwd, which collides with the demo/ directory + # itself (same reason deploy-demo.yml always passes -o). + - name: Build Go (wasm demo packages) + if: matrix.language == 'go' + shell: bash + env: + GOOS: js + GOARCH: wasm + run: | + go build -o /tmp/demo.wasm ./demo + go build -o /tmp/demo-agents.wasm ./demo-agents + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 000000000..3e7b1f5e7 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,197 @@ +name: Security + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + schedule: + - cron: '0 8 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + secrets: + name: Secret scanning (Gitleaks) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Using the CLI directly rather than gitleaks/gitleaks-action@v2: that + # action now requires a paid GITLEAKS_LICENSE for organization + # repositories (see gitleaks/gitleaks-action#issues, "-announcement"). + # The gitleaks CLI itself stays free and open source. + - name: Install Gitleaks + run: | + curl -fsSL https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz \ + | tar -xz -C /usr/local/bin gitleaks + + - name: Run Gitleaks + run: gitleaks detect --source . --config .gitleaks.toml --redact + + dependency-scan: + name: Dependency scan (Trivy fs) + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + security-events: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Offline scan: the pom.xml bindings pull in parent POM lookups against + # Maven Central for version resolution, which is flaky under CI's + # shared egress IPs (rate limiting) and unnecessary - the lockfiles + # already pin resolved versions. + - name: Trivy filesystem scan (block on critical/high) + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + env: + TRIVY_OFFLINE_SCAN: true + with: + scan-type: fs + scan-ref: . + severity: CRITICAL,HIGH + exit-code: '1' + ignore-unfixed: true + format: sarif + output: trivy-fs-critical-high.sarif + limit-severities-for-sarif: 'true' + + - name: Upload SARIF (critical/high) + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-fs-critical-high.sarif + category: trivy-fs-critical-high + + - name: Trivy filesystem scan (warn on medium) + if: always() + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + env: + TRIVY_OFFLINE_SCAN: true + with: + scan-type: fs + scan-ref: . + severity: MEDIUM + exit-code: '0' + format: table + + govulncheck: + name: Dependency scan (govulncheck) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + # demo and demo-agents are WASM-only (import syscall/js); see the + # comment in .github/workflows/go.yml for why they're excluded from + # the main scan and checked separately under GOOS=js GOARCH=wasm. + - name: govulncheck (native) + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck $(go list ./... 2>/dev/null | grep -Ev '/demo$|/demo-agents$') + + - name: govulncheck (wasm demo) + env: + GOOS: js + GOARCH: wasm + run: govulncheck ./demo/... + + - name: govulncheck (wasm agent barrier demo) + env: + GOOS: js + GOARCH: wasm + run: govulncheck ./demo-agents/... + + iac-scan: + name: IaC / container config scan (Trivy config) + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + security-events: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Trivy config scan (block on critical/high) + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: config + scan-ref: . + severity: CRITICAL,HIGH + exit-code: '1' + format: sarif + output: trivy-config-critical-high.sarif + limit-severities-for-sarif: 'true' + + - name: Upload SARIF (critical/high) + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-config-critical-high.sarif + category: trivy-config-critical-high + + - name: Trivy config scan (warn on medium) + if: always() + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: config + scan-ref: . + severity: MEDIUM + exit-code: '0' + format: table + + image-scan: + name: Container image scan (Trivy image) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + security-events: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build runtime image + run: docker build --target runtime -t joltrin:scan . + + - name: Trivy image scan (block on critical/high) + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: joltrin:scan + severity: CRITICAL,HIGH + exit-code: '1' + ignore-unfixed: true + format: sarif + output: trivy-image-critical-high.sarif + limit-severities-for-sarif: 'true' + + - name: Upload SARIF (critical/high) + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-image-critical-high.sarif + category: trivy-image-critical-high + + - name: Trivy image scan (warn on medium) + if: always() + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: joltrin:scan + severity: MEDIUM + exit-code: '0' + format: table diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 000000000..c4e35d941 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,33 @@ +title = "joltrin gitleaks config" + +[extend] +useDefault = true + +[allowlist] +description = "Baseline exceptions for known-safe test fixtures and generated artifacts" +paths = [ + '''(^|/)_test\.go$''', + '''(^|/)node_modules/''', + '''(^|/)_site/''', + '''(^|/)test-results/''', + '''(^|/)playwright-report/''', + '''(^|/)go\.sum$''', + '''(^|/)go\.work\.sum$''', + '''(^|/)package-lock\.json$''', + # Generated AI training/demo dataset content that used to be tracked + # before it was gitignored (see .gitignore). Its record IDs (disease + # names, UUIDs) trip the generic-api-key rule but hold no credentials. + '''^ai/data/(doctor_core|doctor|nurse_local)/''', + # Deleted legacy ASP.NET scaffold; its Application Insights + # instrumentation key is a client-visible telemetry ID, not a secret. + '''(^|/)Sop\.AzureWebApp/''', +] + +regexes = [ + # Placeholder credentials used in test fixtures and README examples, + # never real secrets. Kept narrow and literal on purpose. + '''legacy-secret''', + '''remote-client-test-secret''', + '''secret_password''', + '''my-secret-token''', +] diff --git a/Dockerfile.nocov b/Dockerfile.nocov index 8df2e662e..ed9ed1756 100644 --- a/Dockerfile.nocov +++ b/Dockerfile.nocov @@ -33,15 +33,29 @@ FROM golang:1.26.8-alpine AS build-and-test # Install Redis server for Alpine RUN apk add --no-cache redis +# Unprivileged user to run the test suite as. Go's module cache and build +# cache need a writable, non-root-owned HOME since this stage does its own +# `go mod download` at test time (it doesn't inherit the builder stage's +# cache mount) and redis-server only binds an unprivileged port, so nothing +# here needs root. +RUN addgroup -g 10001 -S sop && \ + adduser -u 10001 -S sop -G sop -h /home/sop && \ + mkdir -p /home/sop/go /home/sop/.cache /var/lib/sop && \ + chown -R sop:sop /home/sop /var/lib/sop + +ENV HOME=/home/sop +ENV GOPATH=/home/sop/go +ENV GOCACHE=/home/sop/.cache/go-build + WORKDIR /app -COPY --from=builder /src /app +COPY --from=builder --chown=sop:sop /src /app -# Create the data path folder & the env var. -RUN mkdir -p /var/lib/sop ENV datapath=/var/lib/sop # Copy the entrypoint script that runs tests with NO coverage COPY docker-entrypoint-nocov.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/docker-entrypoint-nocov.sh +USER sop:sop + CMD ["docker-entrypoint-nocov.sh"] diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..ffcfde225 --- /dev/null +++ b/Makefile @@ -0,0 +1,66 @@ +.PHONY: security-scan lint-sec sast sca secrets-scan iac-scan + +# Mirrors the checks run in .github/workflows/security.yml and codeql.yml +# so issues surface locally before a push, not after CI runs. + +lint-sec: + @echo "== gofmt ==" + @unformatted=$$(gofmt -l . | grep -v vendor || true); \ + if [ -n "$$unformatted" ]; then \ + echo "Files need gofmt:"; echo "$$unformatted"; exit 1; \ + fi + @echo "== go vet ==" + @go vet $$(go list ./... 2>/dev/null | grep -Ev '/demo$$|/demo-agents$$') + +GOVULNCHECK := $(shell go env GOPATH)/bin/govulncheck + +# Snapshots tracked + non-ignored files into a temp dir before handing it to +# trivy, rather than scanning the live working tree directly. Local runtime +# state that's gitignored (dev server session data under +# tools/httpserver/sessions/, .sop_data/, etc.) never ends up in a git +# checkout, and scanning it locally produces alarming noise about "leaked" +# tokens that were never committed anywhere. +snapshot-tracked = tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + git ls-files -z --cached --others --exclude-standard | tar -c --null -T - | tar -x -C "$$tmpdir"; + +sca: + @echo "== govulncheck ==" + @test -x "$(GOVULNCHECK)" || go install golang.org/x/vuln/cmd/govulncheck@latest + @$(GOVULNCHECK) $$(go list ./... 2>/dev/null | grep -Ev '/demo$$|/demo-agents$$') + @GOOS=js GOARCH=wasm $(GOVULNCHECK) ./demo/... + @GOOS=js GOARCH=wasm $(GOVULNCHECK) ./demo-agents/... + @if command -v trivy >/dev/null 2>&1; then \ + echo "== trivy fs (critical/high block, medium warn) =="; \ + $(snapshot-tracked) \ + trivy fs --offline-scan --severity CRITICAL,HIGH --exit-code 1 --ignore-unfixed "$$tmpdir" && \ + trivy fs --offline-scan --severity MEDIUM --exit-code 0 "$$tmpdir"; \ + else \ + echo "trivy not installed locally, skipping fs scan (see .github/workflows/security.yml for CI coverage): https://trivy.dev/latest/getting-started/installation/"; \ + fi + +secrets-scan: + @if command -v gitleaks >/dev/null 2>&1; then \ + gitleaks detect --source . --config .gitleaks.toml --redact ; \ + else \ + echo "gitleaks not installed locally: https://github.com/gitleaks/gitleaks#installing"; \ + exit 1; \ + fi + +iac-scan: + @if command -v trivy >/dev/null 2>&1; then \ + echo "== trivy config (critical/high block, medium warn) =="; \ + $(snapshot-tracked) \ + trivy config --severity CRITICAL,HIGH --exit-code 1 "$$tmpdir" && \ + trivy config --severity MEDIUM --exit-code 0 "$$tmpdir"; \ + else \ + echo "trivy not installed locally: https://trivy.dev/latest/getting-started/installation/"; \ + exit 1; \ + fi + +sast: + @echo "SAST (CodeQL) runs in CI on push/PR; see .github/workflows/codeql.yml." + @echo "For a local approximation, run 'make lint-sec' plus 'go vet' above." + +security-scan: lint-sec sca secrets-scan iac-scan + @echo "All local security checks passed." diff --git a/SECURITY.md b/SECURITY.md index dd2ea1637..392a3bb30 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -20,10 +20,27 @@ We aim to acknowledge reports within a few business days. Timelines for a fix de ## How Dependencies Are Monitored -- Every push and pull request runs `govulncheck` against the Go module graph in CI (`.github/workflows/go.yml`). +- Every push and pull request runs `govulncheck` against the Go module graph in CI (`.github/workflows/go.yml`, `.github/workflows/security.yml`). - GitHub's Dependabot security alerts are enabled on this repository for supported ecosystems (Go, npm, Maven, Cargo). - Recent releases have included dependency bumps (`go-git`, `cel-go`, `golang.org/x/crypto`, `golang.org/x/net`, `jackson-databind`) specifically to close open advisories; see `CHANGELOG.md` for the history. +## Pipeline Security Controls + +Every push and pull request against `master` runs: + +- **SAST** - CodeQL (`.github/workflows/codeql.yml`), Go and JavaScript/TypeScript, `security-extended` query pack. Results appear in the repository's Security tab. +- **Secret scanning** - Gitleaks (`.github/workflows/security.yml`), config in `.gitleaks.toml`. +- **Dependency scanning (SCA)** - `govulncheck` plus Trivy filesystem scan. Critical/high severity findings fail the build; medium severity is reported but non-blocking. +- **Container and config scanning** - Trivy config scan against the three Dockerfiles, and a Trivy image scan of the built runtime image, with the same critical/high-blocks, medium-warns policy. + +Run the same checks locally before pushing with `make security-scan` (or the individual `make lint-sec`, `make sca`, `make secrets-scan`, `make iac-scan` targets). `secrets-scan` and `iac-scan` require `gitleaks` and `trivy` on your PATH respectively. + +### Claude Code review and remediation + +- `.github/workflows/claude-review.yml` runs an automated security-focused review on every PR open/update and posts findings as a comment. It has read-only tool access and never modifies files. +- `.github/workflows/claude-remediation.yml` runs only when a maintainer comments `/remediate` or `/claude fix` on a PR. It diagnoses the failing checks and posts a suggested patch as a comment for a human to apply; it does not commit anything itself. +- Both require the `ANTHROPIC_API_KEY` repository secret. Without it, the review/remediation jobs fail but no other CI is affected. + ## Scope This policy covers the Go core engine, the Python, C#, Java, and Rust bindings, the WebAssembly browser demo, and the standalone `tools/httpserver` Data Manager. It does not cover third-party services you choose to run alongside Joltrin (Redis, cloud storage, etc.), which have their own security policies. diff --git a/bindings/Dockerfile.build b/bindings/Dockerfile.build index a5abe95cf..1da7709b4 100644 --- a/bindings/Dockerfile.build +++ b/bindings/Dockerfile.build @@ -16,6 +16,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN curl -fsSL https://ziglang.org/download/0.13.0/zig-linux-x86_64-0.13.0.tar.xz | tar -xJ -C /usr/local \ && ln -s /usr/local/zig-linux-x86_64-0.13.0/zig /usr/local/bin/zig +# Unprivileged user for the actual build (bindings/build_in_docker.sh runs +# `docker run` with no volume mount and copies artifacts out via `docker cp` +# afterward, so there's no host-UID bind-mount concern). The --mount=cache +# module cache above doesn't persist into the image, so build.sh downloads +# modules again at container-run time and needs a writable GOPATH/GOCACHE. +RUN groupadd -g 10001 sop && useradd -u 10001 -g sop -m -d /home/sop sop + WORKDIR /app # Download modules first with layer caching @@ -33,4 +40,10 @@ RUN --mount=type=cache,target=/go/pkg/mod \ go mod download # Copy source code -COPY . . +COPY --chown=sop:sop . . + +ENV HOME=/home/sop +ENV GOPATH=/home/sop/go +ENV GOCACHE=/home/sop/.cache/go-build + +USER sop:sop