From 8b73b1109517da2cf8daefcaf0336f00a54d2ce5 Mon Sep 17 00:00:00 2001 From: tusharentheoria Date: Tue, 4 Aug 2026 01:03:47 +0530 Subject: [PATCH 1/2] ci: fix the three red crosses (lint, trivy pin, Pages) All 12 unit-test jobs were already green; three jobs were failing for three unrelated reasons. 1. SAST + lint - ruff found 7 pre-existing F401 unused imports in bus.py, hunt.py, store.py, worker.py and tests/test_store.py. Removed them. 255 tests still pass, so nothing was re-exported through those imports. ROOT CAUSE: the linters were installed unpinned, so a newer ruff widened F401 and broke CI with no code change. Pinned ruff==0.16.1 and bandit[toml]==1.9.4 - selecting rules in ruff.toml does not help when a release changes what an existing rule detects. 2. SAST + lint (second, hidden failure) - bandit also exited 1 with 10 Medium findings. It never ran in CI because the lint step failed first, so fixing ruff alone would only have moved the cross. Reviewed all 10; every one is a false positive or a deliberate control, so each carries a per-line '# nosec - ' rather than lowering the -ll threshold: * 7x B608 - the SQL is concatenated from STATIC predicate fragments while every user value is bound through a ? placeholder (and worker.py builds its IN list as a join of '?'). Properly parameterised. * 1x B608 - the schema-migration insert; executescript takes no params, version is int()-cast and name is ''-escaped. * 2x B104 - config.py:181 is the guard that REFUSES to bind 0.0.0.0 in prod, i.e. bandit flagged the security control itself; space_server binds all interfaces by design for the container/Space console. * 1x B310 - urlopen against a hardcoded http://127.0.0.1 loopback upstream. 3. image build + filesystem scan - 'Unable to resolve action aquasecurity/trivy-action@0.36.0'. The action publishes v-PREFIXED tags, so the bare 0.36.0 does not exist and the job died in 'Set up job' before any step ran. Now v0.36.0. This is the same mistake the previous fix made with 0.24.0, so the comment records how to verify a tag. 4. pages / build console - 'Create Pages site failed: Resource not accessible by integration'. configure-pages@v5 with enablement:true cannot create a Pages site with GITHUB_TOKEN: pages:write permits deploying, not creating. Enabled Pages once on the repo with build_type=workflow; the workflow is unchanged and enablement:true is now a no-op that keeps working for clones. Verified locally: ruff exit 0, bandit exit 0, 255 passed + 7 subtests, and both workflow YAMLs parse. --- .github/workflows/ci.yml | 23 +++++++++++++---------- sentinelai/bus.py | 4 ++-- sentinelai/config.py | 2 +- sentinelai/hunt.py | 2 +- sentinelai/space_server.py | 4 ++-- sentinelai/store.py | 12 ++++++------ sentinelai/worker.py | 6 +++--- tests/test_store.py | 1 - 8 files changed, 28 insertions(+), 26 deletions(-) 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/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 From 63b139dedd979eaf8c4787bf36e00c37ddc0f8bf Mon Sep 17 00:00:00 2001 From: tusharentheoria Date: Tue, 4 Aug 2026 01:10:53 +0530 Subject: [PATCH 2/2] fix(docker): copy docs/openapi.json so the in-image test suite can run With the trivy action reference fixed, the 'image build + filesystem scan' job got far enough to reveal a real second failure: FileNotFoundError: [Errno 2] No such file or directory: '/app/docs/openapi.json' test_openapi_parity.TestCommittedSpec.test_the_committed_file_is_current The Dockerfile copies sentinelai/, tests/, requirements.txt and README.md, then runs the whole suite inside the image - but never copied docs/. So this test passed on the runner and failed only inside the build, which is why it was invisible until now. Copied the single 27 KB file the test needs rather than weakening the test or skipping it inside the image: the Dockerfile's own comment says the image is only published if its suite passes, so silently skipping a test there would hollow out that guarantee. Dockerfile.space is unaffected - it does not run the suite. Verified: the committed spec equals the generated one (5 parity tests pass), so the missing file was the only fault. The image build itself could not be run locally - the Docker daemon is not running on this machine - so CI is the proof. --- Dockerfile | 5 +++++ 1 file changed, 5 insertions(+) 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.