diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c0c8a8..fda818e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,12 +120,13 @@ jobs: with: python-version: "3.13" cache: pip - - run: pip install -r requirements.txt ruff bandit[toml] + # PINNED DELIBERATELY. Unpinned linters made this job fail with no code + # change: a newer ruff widened F401 and flagged 7 pre-existing unused + # imports. Selecting rules in ruff.toml is not enough - a new release can + # still change what an existing rule detects. Bump these on purpose. + - run: pip install -r requirements.txt ruff==0.16.1 "bandit[toml]==1.9.4" - name: Lint - # ruff.toml selects the rule set explicitly so a new ruff release - # cannot redefine what 'passing' means. The version is printed so the - # exact linter is recoverable from the log and can be pinned the day - # this project needs bit-reproducible CI. + # The version is printed so the exact linter is recoverable from the log. run: | ruff --version ruff check --config ruff.toml sentinelai tests @@ -203,11 +204,13 @@ jobs: fi echo "fails closed without a signing secret, as designed" - name: Trivy filesystem scan - # Pinned to a tag Dependabot has confirmed exists. 0.24.0 stopped - # resolving, which fails the job in 'Set up job' before any step - # runs - a two-second red cross that looks like a build failure - # and is really a missing action reference. - uses: aquasecurity/trivy-action@0.36.0 + # NOTE THE 'v'. aquasecurity/trivy-action publishes v-prefixed tags + # (v0.36.0); the bare form (0.36.0, 0.24.0) does not resolve. An + # unresolvable action fails in 'Set up job' before any step runs - a + # two-second red cross that looks like a build failure and is really a + # missing action reference. Verify with: + # gh api repos/aquasecurity/trivy-action/tags --jq '.[0:5][].name' + uses: aquasecurity/trivy-action@v0.36.0 with: scan-type: fs scan-ref: . diff --git a/Dockerfile b/Dockerfile index 3afdec8..5262c75 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,11 @@ RUN pip install --no-cache-dir -r requirements.txt COPY sentinelai/ ./sentinelai/ COPY tests/ ./tests/ COPY README.md ./ +# tests/test_openapi_parity.py asserts the committed spec equals the generated +# one, so the in-image suite needs it. Without this the build fails with +# FileNotFoundError on /app/docs/openapi.json - a test that passes on the runner +# and only fails inside the image. +COPY docs/openapi.json ./docs/openapi.json # The image is only published if its own test suite passes inside the image. # A container that cannot prove its model code works is not a release artifact. diff --git a/sentinelai/bus.py b/sentinelai/bus.py index 798d3ef..45cd007 100644 --- a/sentinelai/bus.py +++ b/sentinelai/bus.py @@ -21,8 +21,8 @@ from __future__ import annotations import json -from dataclasses import dataclass, field -from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Optional, Tuple from . import obs diff --git a/sentinelai/config.py b/sentinelai/config.py index 5d57717..b542334 100644 --- a/sentinelai/config.py +++ b/sentinelai/config.py @@ -178,7 +178,7 @@ def from_env(cls, environ: Optional[Mapping[str, str]] = None) -> "Settings": # Binding a security service to every interface is a deliberate act, so # it must be a deliberate act in production rather than a leftover. - if app_env == "prod" and host == "0.0.0.0" and env.get("SENTINELAI_ALLOW_PUBLIC_BIND") != "1": + if app_env == "prod" and host == "0.0.0.0" and env.get("SENTINELAI_ALLOW_PUBLIC_BIND") != "1": # nosec B104 - this IS the guard that refuses the public bind problems.append( "refusing to bind 0.0.0.0 in prod without SENTINELAI_ALLOW_PUBLIC_BIND=1" ) diff --git a/sentinelai/hunt.py b/sentinelai/hunt.py index 55e6242..7f97370 100644 --- a/sentinelai/hunt.py +++ b/sentinelai/hunt.py @@ -34,7 +34,7 @@ import argparse import json import re -from dataclasses import dataclass, field as dc_field +from dataclasses import dataclass from pathlib import Path from typing import Any, Sequence diff --git a/sentinelai/space_server.py b/sentinelai/space_server.py index 6982747..cd2de9d 100644 --- a/sentinelai/space_server.py +++ b/sentinelai/space_server.py @@ -67,7 +67,7 @@ def _proxy(self) -> None: req.add_header(header, value) try: - with urllib.request.urlopen(req, timeout=30) as upstream_response: + with urllib.request.urlopen(req, timeout=30) as upstream_response: # nosec B310 - upstream is a hardcoded http://127.0.0.1 loopback URL status = upstream_response.status payload = upstream_response.read() headers = upstream_response.headers.items() @@ -153,7 +153,7 @@ def main() -> None: ConsoleHandler.upstream = f"http://127.0.0.1:{args.api_port}" handler = partial(ConsoleHandler, directory=str(dist)) - public = ThreadingHTTPServer(("0.0.0.0", args.port), handler) + public = ThreadingHTTPServer(("0.0.0.0", args.port), handler) # nosec B104 - the console server binds all interfaces by design (container/Space) print(f"console on http://0.0.0.0:{args.port} (serving {dist})", flush=True) try: public.serve_forever() diff --git a/sentinelai/store.py b/sentinelai/store.py index d5a0a58..9eca9ca 100644 --- a/sentinelai/store.py +++ b/sentinelai/store.py @@ -24,7 +24,7 @@ import threading import time from pathlib import Path -from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple +from typing import Any, Dict, List, Optional, Tuple __all__ = [ "Store", "StoreError", "ConflictError", "NotFound", "InvalidTransition", @@ -329,7 +329,7 @@ def migrate(self) -> int: # inlined for the same reason: executescript takes no parameters. # Every value interpolated here is a module constant, never input. bookkeeping = ( - "INSERT INTO schema_migrations(version, name, applied_at) VALUES (" + "INSERT INTO schema_migrations(version, name, applied_at) VALUES (" # nosec B608 - version is int()-cast, name is ''-escaped; both module constants + str(int(version)) + ", '" + name.replace("'", "''") + "', '" + _now() + "');") script = "BEGIN IMMEDIATE;" + sql + bookkeeping + "COMMIT;" @@ -398,7 +398,7 @@ def list_alerts(self, limit: int = 50, cursor: Optional[str] = None, where.append("(probability < ? OR (probability = ? AND id < ?))") params.extend([float(last_p), float(last_p), last_id]) clause = (" WHERE " + " AND ".join(where)) if where else "" - sql = ("SELECT * FROM alerts" + clause + sql = ("SELECT * FROM alerts" + clause # nosec B608 - clause joins static predicates; all values bound via ? + " ORDER BY probability DESC, id DESC LIMIT ?") params.append(limit + 1) rows = _rows(self.connect().execute(sql, params)) @@ -526,7 +526,7 @@ def list_cases(self, limit: int = 50, cursor: Optional[str] = None, where.append("(updated_at < ? OR (updated_at = ? AND id < ?))") params.extend([last_ts, last_ts, last_id]) clause = (" WHERE " + " AND ".join(where)) if where else "" - sql = ("SELECT * FROM cases" + clause + sql = ("SELECT * FROM cases" + clause # nosec B608 - clause joins static predicates; all values bound via ? + " ORDER BY updated_at DESC, id DESC LIMIT ?") params.append(limit + 1) rows = _rows(self.connect().execute(sql, params)) @@ -624,7 +624,7 @@ def audit_page(self, limit: int = 100, cursor: Optional[str] = None) -> Dict[str params.append(int(last_seq)) params.append(limit + 1) rows = _rows(self.connect().execute( - "SELECT * FROM audit_log" + clause + " ORDER BY seq DESC LIMIT ?", params)) + "SELECT * FROM audit_log" + clause + " ORDER BY seq DESC LIMIT ?", params)) # nosec B608 - clause joins static predicates; all values bound via ? next_cursor = None if len(rows) > limit: rows = rows[:limit] @@ -654,7 +654,7 @@ def fetch_events(self, consumer: str, topic: Optional[str] = None, params.append(topic) params.append(int(limit)) return _rows(conn.execute( - "SELECT * FROM outbox WHERE id > ?" + clause + " ORDER BY id LIMIT ?", + "SELECT * FROM outbox WHERE id > ?" + clause + " ORDER BY id LIMIT ?", # nosec B608 - clause joins static predicates; all values bound via ? params)) def commit_offset(self, consumer: str, last_id: int) -> None: diff --git a/sentinelai/worker.py b/sentinelai/worker.py index 17f8e85..8becf2c 100644 --- a/sentinelai/worker.py +++ b/sentinelai/worker.py @@ -17,7 +17,7 @@ import threading import time -from typing import Any, Dict, List, Optional +from typing import Any, Dict, Optional from . import obs from .bus import Bus @@ -187,12 +187,12 @@ def job_retention(self) -> Dict[str, Any]: if names: placeholders = ",".join("?" for _ in names) row = conn.execute( - "SELECT MIN(last_id) FROM consumer_offsets WHERE consumer IN (" + "SELECT MIN(last_id) FROM consumer_offsets WHERE consumer IN (" # nosec B608 - placeholders is a join of '?'; names bound as params + placeholders + ")", names).fetchone() committed = row[0] if row is not None else None # A registered consumer with no offset row has committed nothing. if committed is not None and len(names) == int(conn.execute( - "SELECT COUNT(*) FROM consumer_offsets WHERE consumer IN (" + "SELECT COUNT(*) FROM consumer_offsets WHERE consumer IN (" # nosec B608 - placeholders is a join of '?'; names bound as params + placeholders + ")", names).fetchone()[0]): safe_below = int(committed) cutoff = time.strftime( diff --git a/tests/test_store.py b/tests/test_store.py index 9a3322c..53ec80c 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -6,7 +6,6 @@ """ import json -import os import sqlite3 import tempfile import threading