From ccf6541db111b28e83711a5fac70babdf7f5566a Mon Sep 17 00:00:00 2001 From: Jules GRIVOT PELISSON Date: Thu, 29 Oct 2026 22:23:13 -0400 Subject: [PATCH] ADD: scan secrets, Python, dependencies and workflows in CI, and pin every action to a commit --- .github/dependabot.yml | 10 +++ .github/workflows/codeql.yml | 42 +++++++++++++ .github/workflows/security.yml | 99 ++++++++++++++++++++++++++++++ .github/workflows/server-image.yml | 8 ++- .github/workflows/tests.yml | 11 ++-- .gitleaksignore | 12 ++++ scripts/image_release.py | 3 +- scripts/server_ctl.py | 3 +- scripts/update_agent.py | 6 +- server/benchmark_client.py | 6 +- server/config.py | 3 +- server/main.py | 3 +- server/serve.py | 3 +- server/wire/update_check.py | 3 +- 14 files changed, 196 insertions(+), 16 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/security.yml create mode 100644 .gitleaksignore diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b69d60d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + # The workflows pin each action to a commit; this keeps those pins current. + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + groups: + actions: + patterns: ["*"] diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..39d2b9f --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,42 @@ +name: codeql + +# GitHub's semantic analysis, for Python and for these workflows. Findings +# land in the Security tab rather than failing the run. +on: + push: + branches: [main] + pull_request: + schedule: + - cron: "41 6 * * 1" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: ${{ matrix.language }} + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: [python, actions] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + with: + languages: ${{ matrix.language }} + build-mode: none + queries: security-extended + - uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + with: + category: /language:${{ matrix.language }} diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..4ead983 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,99 @@ +name: security + +# Four checks a server holding confidential imaging depends on: no credential +# lands in the history, the Python carries no known-dangerous pattern, the +# API's dependencies carry no published vulnerability, and the workflows +# themselves cannot be turned against the repository. +on: + push: + branches: [main] + pull_request: + # Weekly, so a rule or an advisory published upstream reaches code nobody + # has touched since. + schedule: + - cron: "29 6 * * 1" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + secrets: + name: secrets (gitleaks) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # The whole history: a token removed in a later commit is still public. + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + fetch-depth: 0 + persist-credentials: false + # The image, pinned by digest, rather than gitleaks-action, which needs + # a paid licence on an organisation's repository. Known placeholders are + # listed, one fingerprint each, in .gitleaksignore. + - name: Scan the history + run: > + docker run --rm -v "$PWD:/repo:ro" + ghcr.io/gitleaks/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f + git /repo --no-banner --redact --exit-code 1 + + python: + name: python (bandit) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.13" + - name: Install bandit + run: python -m pip install "bandit==1.9.*" + # Medium severity and up fails the job. Tests and benchmarks are left + # out: their fixed /tmp paths and fake servers are what they are for. A + # finding that is not one is marked `# nosec ` with the reason on + # the line above. B608 (SQL built from strings) is skipped: there is no + # SQL database, and every hit is an HTML page assembled from constants. + - name: Scan + run: bandit -r . -x './.git,*/tests/*,./benchmarks' -s B608 -ll -q + + dependencies: + name: dependencies (pip-audit) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.13" + - name: Install pip-audit + run: python -m pip install "pip-audit==2.*" + # requirements-api.txt is all the server image installs, unpinned, so it + # resolves here to what the next image build would get. A tool's own + # dependencies live in the tools repository and are audited there. + - name: Audit the API's dependencies + run: pip-audit -r server/requirements-api.txt --progress-spinner off + + workflows: + name: workflows (zizmor) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.13" + - name: Install zizmor + run: python -m pip install "zizmor==1.*" + # Offline: the online audits call the GitHub API, and the runner's + # token is read-only here anyway. + - name: Audit + run: zizmor --offline --min-severity medium .github/workflows diff --git a/.github/workflows/server-image.yml b/.github/workflows/server-image.yml index 794573b..dd2d752 100644 --- a/.github/workflows/server-image.yml +++ b/.github/workflows/server-image.yml @@ -37,9 +37,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 with: fetch-depth: 0 + persist-credentials: false - name: Decide what to publish id: plan @@ -71,7 +72,10 @@ jobs: - name: Log in to ghcr.io if: steps.plan.outputs.publish == 'true' - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + env: + REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REGISTRY_USER: ${{ github.actor }} + run: echo "$REGISTRY_TOKEN" | docker login ghcr.io -u "$REGISTRY_USER" --password-stdin - name: Build if: steps.plan.outputs.publish == 'true' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 30b1837..4a4af2f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,7 +23,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + persist-credentials: false # The server image is BUILT here, from this commit's # docker/server.Dockerfile, under the name docker-compose.yml runs. It @@ -89,8 +91,9 @@ jobs: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 with: + persist-credentials: false # Full history, not a shallow clone: the step below diffs against the # PR's merge base, which on a long-lived branch is far more than a # couple of commits back. `fetch-depth: 2` makes that `git diff` fail @@ -125,7 +128,7 @@ jobs: echo "run=true" >> "$GITHUB_OUTPUT" fi - - uses: docker/setup-buildx-action@v4 + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4 if: steps.changed.outputs.run == 'true' # Built against docker/fixtures/, not the real tools: three tools with @@ -134,7 +137,7 @@ jobs: # still resolves without pulling a CUDA stack or the other repository. - name: Build with the fixture tools if: steps.changed.outputs.run == 'true' - uses: docker/build-push-action@v7 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7 with: context: . file: docker/Dockerfile diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..4b0c8e7 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,12 @@ +# Placeholders in server/README.md examples (`Bearer your-...-token`) and the +# word "tokens" in prose: no credential. One fingerprint per finding, as gitleaks +# prints it; a real secret stays reported. +0d2ca921460692722561d2cfab21a0a2bfdfeafe:server/README.md:curl-auth-header:213 +0d2ca921460692722561d2cfab21a0a2bfdfeafe:server/README.md:curl-auth-header:219 +0d2ca921460692722561d2cfab21a0a2bfdfeafe:server/README.md:curl-auth-header:226 +327f665f56a4b141dafa75c1d2ac0178b2e18c04:server/README.md:generic-api-key:420 +34f6e4ca2589f3c3f7e6059fd79681d77fd7c298:server/README.md:curl-auth-header:98 +ddb07475e3dba207f0b48f672869fb5cfd0d8949:server/README.md:curl-auth-header:98 +d35a99467206d75f992884b4f737048bb855182d:server/README.md:curl-auth-header:71 +d35a99467206d75f992884b4f737048bb855182d:server/README.md:curl-auth-header:76 +fb990dcab7ffc18b6ddc75e6a126a101a0d0312d:server/README.md:curl-auth-header:40 diff --git a/scripts/image_release.py b/scripts/image_release.py index 8849d1d..23ba9f1 100644 --- a/scripts/image_release.py +++ b/scripts/image_release.py @@ -59,7 +59,8 @@ def _bearer(challenge: str, repository: str) -> str: if fields.get("service"): query["service"] = fields["service"] try: - with urllib.request.urlopen(urllib.request.Request( + # HTTPS registry realm read from the registry's own challenge. + with urllib.request.urlopen(urllib.request.Request( # nosec B310 realm + "?" + urllib.parse.urlencode(query), headers=_HEADERS), timeout=20) as response: payload = json.loads(response.read().decode("utf-8") or "{}") except (OSError, ValueError): diff --git a/scripts/server_ctl.py b/scripts/server_ctl.py index e7b59b2..8b3d3a4 100755 --- a/scripts/server_ctl.py +++ b/scripts/server_ctl.py @@ -417,7 +417,8 @@ def tls_context(url: str) -> ssl.SSLContext: def server_urlopen(target, timeout: float): """`urllib.request.urlopen` for this server: HTTPS, verified (tls_context).""" url = target.full_url if isinstance(target, urllib.request.Request) else target - return urllib.request.urlopen(target, timeout=timeout, context=tls_context(url)) + # The URL is this server's own https address. + return urllib.request.urlopen(target, timeout=timeout, context=tls_context(url)) # nosec B310 def ensure_env(service: str, bind_addr=None, token=None, port=None) -> str: diff --git a/scripts/update_agent.py b/scripts/update_agent.py index df1e599..86d4b6b 100644 --- a/scripts/update_agent.py +++ b/scripts/update_agent.py @@ -140,7 +140,8 @@ def _load_release_diff(): panel and this agent can never disagree about a commit.""" module = types.ModuleType("release_diff_for_agent") module.__file__ = "" - exec(compile(_release_diff_source(), module.__file__, "exec"), module.__dict__) + # Compiles the repository's own release_diff.py at HEAD, not input. + exec(compile(_release_diff_source(), module.__file__, "exec"), module.__dict__) # nosec B102 return module @@ -762,7 +763,8 @@ def apply(self, request: dict) -> None: self.wait_idle("Before restarting", unreachable_grace=UNREACHABLE_GRACE_SECONDS) self.phase("Restarting the server." if restart == RESTART else "Recreating the server's container.") if self.args.restart_cmd: - done = subprocess.run(self.args.restart_cmd, shell=True, capture_output=True, text=True, timeout=600) + # --restart-cmd is the operator's own command line, passed on the agent's argv. + done = subprocess.run(self.args.restart_cmd, shell=True, capture_output=True, text=True, timeout=600) # nosec B602 else: service = self.args.service or server_ctl.pick_service() verb = ["restart", service] if restart == RESTART else ["up", "-d", "--force-recreate", service] diff --git a/server/benchmark_client.py b/server/benchmark_client.py index bc10f77..2b4c837 100644 --- a/server/benchmark_client.py +++ b/server/benchmark_client.py @@ -133,7 +133,8 @@ def _request(url: str, token: str, data=None, method=None, timeout=POLL_TIMEOUT, request.add_header("Content-Type", "application/x-www-form-urlencoded") for key, value in (headers or {}).items(): request.add_header(key, value) - with urllib.request.urlopen(request, timeout=timeout, context=_tls_context()) as response: + # HTTPS URL of the server under benchmark. + with urllib.request.urlopen(request, timeout=timeout, context=_tls_context()) as response: # nosec B310 return response.status, response.read(), dict(response.headers) @@ -144,7 +145,8 @@ def _send(url: str, token: str, body: bytes, method: str, content_type: str, request.add_header("Content-Type", content_type) for key, value in (headers or {}).items(): request.add_header(key, value) - with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT, context=_tls_context()) as response: + # HTTPS URL of the server under benchmark. + with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT, context=_tls_context()) as response: # nosec B310 return response.status, response.read() diff --git a/server/config.py b/server/config.py index 2a2e0b9..f2784c3 100644 --- a/server/config.py +++ b/server/config.py @@ -26,7 +26,8 @@ class Settings(BaseSettings): # --- core --------------------------------------------------------- API_TOKEN: str # required on /run; no default DEVICE: str = "cpu" # "cuda" on the GPU server - TEMP_DIR: str = "/tmp/inference_server" # emptied once a request is served + # The default only; production sets TEMP_DIR to its own volume. + TEMP_DIR: str = "/tmp/inference_server" # emptied once a request is served # nosec B108 DATA_DIR: str = "/data" # DATA_DIR//{models,testfiles}, read-only DATA_BACKEND: str = "local" # see data_store.py to plug in another diff --git a/server/main.py b/server/main.py index 554f6c9..d294a34 100644 --- a/server/main.py +++ b/server/main.py @@ -3949,4 +3949,5 @@ def dashboard() -> HTMLResponse: if __name__ == "__main__": - uvicorn.run(app, host="0.0.0.0", port=8000) + # Direct-run fallback; serve.py is the entry point and chooses the address. + uvicorn.run(app, host="0.0.0.0", port=8000) # nosec B104 diff --git a/server/serve.py b/server/serve.py index 1f41f2e..6a14b87 100644 --- a/server/serve.py +++ b/server/serve.py @@ -162,7 +162,8 @@ def check(port: int) -> int: try: cert, _key = tls.certificate_paths() - with urllib.request.urlopen(f"https://127.0.0.1:{port}/health", timeout=5, + # Fixed loopback https URL. + with urllib.request.urlopen(f"https://127.0.0.1:{port}/health", timeout=5, # nosec B310 context=tls.loopback_context(cert)) as response: return 0 if response.status == 200 else 1 except Exception: # noqa: BLE001 - a probe answers 1, it never raises diff --git a/server/wire/update_check.py b/server/wire/update_check.py index b8265f3..6711387 100644 --- a/server/wire/update_check.py +++ b/server/wire/update_check.py @@ -56,7 +56,8 @@ def _compare(url: str, base: str, head: str) -> dict: request = urllib.request.Request( f"https://api.github.com/repos/{owner}/{name}/compare/{base}...{head}", headers={"Accept": "application/vnd.github+json", "User-Agent": "visor-serve-update-check"}) - with urllib.request.urlopen(request, timeout=15) as response: + # Fixed https://api.github.com URL. + with urllib.request.urlopen(request, timeout=15) as response: # nosec B310 return json.loads(response.read().decode("utf-8"))