diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..33e17403 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,48 @@ +version: 2 + +# Automated dependency updates (issue #443). +# +# * pip — keep the declared Python dependency floors and dev toolchain +# current. Minor/patch bumps are grouped into a single weekly PR to limit +# noise; majors open individually so breaking changes get their own review. +# * github-actions — keep workflow action references current. The high-trust +# release path (publish.yml) pins actions to immutable commit SHAs (#468); +# Dependabot bumps the SHA and its `# vX` comment together. Other workflows +# track major tags. See docs/security_tooling.md. + +updates: + - package-ecosystem: pip + directory: "/" + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Etc/UTC + open-pull-requests-limit: 5 + groups: + python-minor-patch: + update-types: + - minor + - patch + labels: + - dependencies + commit-message: + prefix: "chore(deps)" + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Etc/UTC + open-pull-requests-limit: 5 + groups: + actions-all: + update-types: + - minor + - patch + labels: + - dependencies + commit-message: + prefix: "chore(ci)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4197b690..5c5bd5c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,6 +97,12 @@ jobs: # version in pyproject.toml. Stdlib-only; no install required. run: python scripts/check_readme_version.py + - name: Security-policy drift check (gating, issue #691) + # Fails when SECURITY.md's supported-version table drifts from the + # package version in pyproject.toml, or when a relative link it + # references no longer resolves. Stdlib-only; no install required. + run: python scripts/check_security_policy.py + - name: Weaver-spec conformance # Round-trip + JSON-Schema validation against the canonical contracts # published at https://weaver-spec.dev/contracts/v0/. Gating because diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..95b4f29d --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,50 @@ +name: CodeQL + +# Static security analysis for the Python sources (issue #689, umbrella #443). +# Runs the default + security-extended query packs on every PR, on pushes to +# main, and on a weekly schedule so newly published queries surface findings +# even when the code is quiet. Findings land in the repository Security tab +# (code scanning). False positives are handled via the documented exception +# process in docs/security_tooling.md (issue #692). + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # Monday 07:00 UTC. Off-hours, just after the weekly scorecard/benchmark + # crons, low contention with the gating CI job. + - cron: "0 7 * * 1" + +permissions: + contents: read + +concurrency: + group: codeql-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + analyze: + name: Analyze (python) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + # Required for CodeQL to upload results to the code-scanning dashboard. + security-events: write + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: python + # ``security-extended`` adds the broader security query suite on top + # of the default pack; pure-Python project, so no build step. + queries: security-extended + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:python" diff --git a/.github/workflows/ossf-scorecard.yml b/.github/workflows/ossf-scorecard.yml new file mode 100644 index 00000000..2cc7191a --- /dev/null +++ b/.github/workflows/ossf-scorecard.yml @@ -0,0 +1,61 @@ +name: OpenSSF Scorecard + +# OpenSSF Scorecard supply-chain health analysis (issue #552, umbrella #443). +# +# Distinct from the *benchmark* scorecard regenerated by +# scorecard-weekly.yml — that one measures routing recall / token savings. +# This workflow runs the OpenSSF Scorecard checks (branch protection, token +# permissions, pinned dependencies, dangerous workflows, maintained, etc.), +# uploads the SARIF to the code-scanning dashboard, and publishes results so +# the README badge resolves. +# +# Applying for the OpenSSF Best Practices badge is a tracked manual step — see +# docs/security_tooling.md. + +on: + branch_protection_rule: + push: + branches: [main] + schedule: + # Monday 08:00 UTC. + - cron: "0 8 * * 1" + +permissions: + contents: read + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + # Upload the results to the code-scanning dashboard. + security-events: write + # Publish results to the OpenSSF REST API so the README badge resolves. + id-token: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Run analysis + uses: ossf/scorecard-action@v2 + with: + results_file: results.sarif + results_format: sarif + # publish_results enables the public badge endpoint at + # api.securityscorecards.dev (see the README badge). + publish_results: true + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: scorecard-results + path: results.sarif + retention-days: 5 + + - name: Upload to code-scanning + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif diff --git a/.github/workflows/pip-audit.yml b/.github/workflows/pip-audit.yml new file mode 100644 index 00000000..a5e2aef9 --- /dev/null +++ b/.github/workflows/pip-audit.yml @@ -0,0 +1,88 @@ +name: pip-audit + +# Dependency vulnerability scan (issue #689, umbrella #443). +# +# Policy (documented in docs/security_tooling.md, issue #692): +# * The CORE runtime dependency set is GATING — a known-vulnerable advisory +# against a core dependency fails the job, because adopters put +# contextweaver in the data path between agents and tools. +# * The DEV/test extra is report-only (continue-on-error): it pulls a large +# transitive tree (crewai, mem0ai, fastmcp, langgraph, langchain-core) that +# would otherwise make the gate noisy and flaky. Findings are still printed +# for triage and recorded in the job summary. +# +# Runs on PRs that touch dependency metadata, on pushes to main, and weekly so +# newly published advisories surface against an otherwise-quiet tree. + +on: + push: + branches: [main] + paths: + - "pyproject.toml" + - ".github/workflows/pip-audit.yml" + pull_request: + paths: + - "pyproject.toml" + - ".github/workflows/pip-audit.yml" + schedule: + # Monday 07:30 UTC, after CodeQL. + - cron: "30 7 * * 1" + +permissions: + contents: read + +concurrency: + group: pip-audit-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + audit-core: + name: Audit core dependencies (gating) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install core package and pip-audit + run: | + python -m pip install --upgrade pip + pip install -e . + pip install pip-audit + + - name: Audit installed environment (gating) + # No --ignore-vuln entries today. Document any future exception here + # with the advisory id and a link to its tracking issue, per the + # exception process in docs/security_tooling.md. + run: pip-audit --progress-spinner off + + audit-dev: + name: Audit dev extra (report-only) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dev extra and pip-audit + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + pip install pip-audit + + - name: Audit installed environment (report-only) + # Report-only: the dev tree carries known advisories that should not gate + # the PR (that is what the gating ``audit-core`` job is for). ``|| true`` + # keeps this check green so it never reads as a blocking failure, while + # the findings stay visible in the log for triage. Promote a specific + # advisory to gating by fixing it in ``audit-core``, per + # docs/security_tooling.md. + run: pip-audit --progress-spinner off || true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a4eb9dcd..a6c7bc10 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,5 +1,11 @@ name: Publish to PyPI and MCP Registry +# Release-path actions are pinned to immutable commit SHAs (issue #468). This is +# the high-trust workflow — it holds ``id-token: write`` and +# ``attestations: write`` — so a moving tag here is a supply-chain risk. The +# ``# vX`` comments record the tag each SHA was resolved from; Dependabot's +# ``github-actions`` updater (.github/dependabot.yml) keeps the SHAs current. + on: release: types: [published] @@ -8,16 +14,62 @@ permissions: contents: read jobs: + verify: + # Release-integrity gate (issue #468). A release publishes straight to PyPI + # and the MCP Registry over OIDC, so this job is the last chance to catch a + # mis-tagged or untested release before it is immutable on PyPI. It proves: + # 1. the release tag matches the package version (no ``v0.16.0`` tag + # shipping a ``0.15.0`` artifact); + # 2. the package builds and its metadata passes ``twine check``; + # 3. the gating test suite is green at the released commit. + # ``publish`` depends on this job, so a red gate blocks the upload. + name: Verify release integrity + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + - name: Check release tag matches package version + # ``GITHUB_REF_NAME`` is the tag the release was cut from (``vX.Y.Z``). + # The package version is the single source of truth in pyproject.toml. + run: | + tag="${GITHUB_REF_NAME}" + version="$(python scripts/check_readme_version.py --print-version)" + if [ "$tag" != "v$version" ]; then + echo "::error::Release tag '$tag' does not match package version 'v$version' (pyproject.toml)." >&2 + exit 1 + fi + echo "Release tag '$tag' matches package version 'v$version'." + + - name: Install package and test/build tooling + run: pip install -e ".[dev]" build twine + + - name: Pre-publish test suite (gating) + run: pytest -q + + - name: Build and check distribution metadata + run: | + python -m build + twine check dist/* + publish: + needs: verify runs-on: ubuntu-latest environment: pypi permissions: - id-token: write # Required for Trusted Publisher (OIDC) + id-token: write # Required for Trusted Publisher (OIDC) + contents: read + attestations: write # Required to attach build-provenance attestations (issue #690) steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" @@ -27,8 +79,16 @@ jobs: - name: Build sdist and wheel run: python -m build + - name: Attest build provenance + # Generates a signed, verifiable provenance attestation for each built + # artifact (issue #690). Verifiable later with ``gh attestation verify + # --repo dgenio/contextweaver``. + uses: actions/attest-build-provenance@96b4a1ef7235a096b17240c259729fdd70c83d45 # v2 + with: + subject-path: "dist/*" + - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 publish-mcp-registry: name: Publish to MCP Registry @@ -38,7 +98,7 @@ jobs: contents: read id-token: write # Required for MCP Registry GitHub OIDC authentication steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Install mcp-publisher run: | diff --git a/AGENTS.md b/AGENTS.md index efefa753..7da8c1ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -213,7 +213,7 @@ make test # python -m pytest --cov=contextweaver --cov-report=term-missing - make example # run all example scripts (includes architectures via the umbrella target) make architectures # run reference architecture scripts under examples/architectures/ make demo # python -m contextweaver demo -make ci # fmt + lint + type + test + drift-check + module-size-check + doc-snippets-check + readme-version-check + example + demo +make ci # fmt + lint + type + test + drift-check + module-size-check + doc-snippets-check + readme-version-check + security-policy-check + example + demo make docs # mkdocs build --clean (docs site) make docs-serve # mkdocs serve (live preview) make benchmark # run benchmark harness (non-gating; writes benchmarks/results/latest.json) @@ -236,6 +236,7 @@ make sweep-scoring # weight sweep for ScoringConfig (#214); writes benchmarks make context-rot # render the context-rot demo: benchmarks/results/context_rot.json + docs/assets/context_rot.svg (#349) make context-rot-check # verify context_rot.svg matches its committed JSON (gating in CI; exits non-zero on drift) make readme-version-check # verify README version references match pyproject.toml (gating in CI; #347) +make security-policy-check # verify SECURITY.md supported series + links match pyproject.toml (gating in CI; #691) make llms # regenerate llms.txt and llms-full.txt from canonical docs make llms-check # verify llms.txt and llms-full.txt are up to date (gating in CI; #389) make weaver-conformance # round-trip + JSON-Schema validate the weaver-spec adapter (CI gating, fetches schemas) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69e552b4..3b1bbb88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Supply-chain & security CI hardening (#443, #689, #690, #691, #692, #468, #552).** + A coordinated security-posture pass under the supply-chain hardening umbrella (#443): + - **CodeQL** code scanning (`.github/workflows/codeql.yml`) with the + `security-extended` query pack, on PR, `main`, and a weekly schedule (#689). + - **pip-audit** dependency scanning (`.github/workflows/pip-audit.yml`): + gating on the core runtime dependency set, report-only for the heavier dev + extra (#689). + - **OpenSSF Scorecard** analysis (`.github/workflows/ossf-scorecard.yml`) + with results published to code scanning and a README badge; the OpenSSF + Best Practices badge application is tracked as a manual step (#552). + - **Dependabot** (`.github/dependabot.yml`) weekly `pip` and `github-actions` + updates, grouped to limit noise (#443). + - **Release-integrity gate** in `publish.yml` (#468): a `verify` job asserts + the release tag matches the `pyproject.toml` version, runs the test suite, + and `twine check`s the built distribution before the publish job runs. + - **Build-provenance attestations** for released artifacts via + `actions/attest-build-provenance` (#690). + - **`security-policy-check`** gate (`scripts/check_security_policy.py`, wired + into `make ci` and `ci.yml`): fails when `SECURITY.md`'s supported-version + table drifts from the package version or links a missing doc. Refreshed the + supported series to `0.16.x` (#691). + - **Security tooling runbook** (`docs/security_tooling.md`) documenting the + triage SLA, ownership, and the false-positive exception process (#692). +- `scripts/check_readme_version.py` gained a `--print-version` flag so the + release-integrity gate reads the package version through the same single + source of truth as the drift guard. + ## [0.16.0] - 2026-06-21 ### Added diff --git a/Makefile b/Makefile index 23b62264..97ec1420 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: fmt lint type test example demo ci ci-full floor-deps tool-smoke docs docs-serve benchmark benchmark-matrix benchmark-routing-scale benchmark-gateway benchmark-primitives sidecar-smoke token-calibration smoke-eval e2e-quality scorecard scorecard-check sweep-scoring architectures llms llms-check weaver-conformance schemas schemas-check context-rot context-rot-check readme-version-check drift drift-check api api-check module-size-check module-size-update doc-snippets-check +.PHONY: fmt lint type test example demo ci ci-full floor-deps tool-smoke docs docs-serve benchmark benchmark-matrix benchmark-routing-scale benchmark-gateway benchmark-primitives sidecar-smoke token-calibration smoke-eval e2e-quality scorecard scorecard-check sweep-scoring architectures llms llms-check weaver-conformance schemas schemas-check context-rot context-rot-check readme-version-check security-policy-check drift drift-check api api-check module-size-check module-size-update doc-snippets-check # Interpreter and pip front-end (issue #712). Default to `python3`, which is what # many modern environments ship (some have no bare `python` on PATH at all). @@ -129,12 +129,17 @@ context-rot-check: readme-version-check: $(PYTHON) scripts/check_readme_version.py +# Fails when SECURITY.md drifts from pyproject.toml's version or links a +# missing doc (issue #691). Stdlib-only; no install required. +security-policy-check: + $(PYTHON) scripts/check_security_policy.py + # The local pass bar. Mirrors the gating CI checks a contributor can run # offline (issue #474): the consolidated generated-artifact drift gate # (issue #522) plus the module-size (#456), doc-snippet (#526), and README # version gates. Weaver-spec conformance and the benchmarks stay CI-only — # they fetch remote schemas / are heavy — and are documented as such. -ci: fmt lint type test drift-check module-size-check doc-snippets-check readme-version-check example demo +ci: fmt lint type test drift-check module-size-check doc-snippets-check readme-version-check security-policy-check example demo # Local equivalents of the two gating CI *jobs* `make ci` cannot mirror cheaply # (issue #710, follow-up to #474). Kept out of `ci` because both build isolated diff --git a/README.md b/README.md index 266f9a78..886180cb 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ [![PyPI version](https://img.shields.io/pypi/v/contextweaver.svg)](https://pypi.org/project/contextweaver/) [![Python versions](https://img.shields.io/pypi/pyversions/contextweaver.svg)](https://pypi.org/project/contextweaver/) [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) +[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/dgenio/contextweaver/badge)](https://scorecard.dev/viewer/?uri=github.com/dgenio/contextweaver) [![Docs](https://img.shields.io/badge/docs-mkdocs--material-blue.svg)](https://dgenio.github.io/contextweaver) [![GitHub Discussions](https://img.shields.io/github/discussions/dgenio/contextweaver)](https://github.com/dgenio/contextweaver/discussions) diff --git a/SECURITY.md b/SECURITY.md index 389bf41d..9e67da71 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,8 +6,12 @@ Only the latest patch release in the current minor series receives security upda | Version | Supported | |---------|-----------| -| 0.14.x | Yes | -| < 0.14 | No | +| 0.16.x | Yes | +| < 0.16 | No | + +> This table is kept honest by `scripts/check_security_policy.py`, a gating CI +> check that fails when the supported series drifts from the package version in +> `pyproject.toml` or when a relative link below stops resolving. For adopter-facing deployment boundaries, data flow, artifact exposure, and hardening guidance, see the @@ -66,3 +70,23 @@ The following are **not** in scope: exhaustion (i.e. leads to data leakage) - **Issues in dependencies** — report these to the upstream project directly; we will update affected dependencies promptly when notified + +## Automated Security Tooling + +The project runs continuous supply-chain and code-scanning automation: + +- **CodeQL** (`.github/workflows/codeql.yml`) — static analysis on every PR, + on `main`, and weekly. +- **OpenSSF Scorecard** (`.github/workflows/ossf-scorecard.yml`) — supply-chain + health checks; results publish to the code-scanning dashboard and the README + badge. +- **pip-audit** (`.github/workflows/pip-audit.yml`) — dependency vulnerability + scanning (gating on core dependencies, report-only for the dev extra). +- **Dependabot** (`.github/dependabot.yml`) — weekly `pip` and `github-actions` + updates. +- **Release integrity** (`.github/workflows/publish.yml`) — tag↔version gate, + pre-publish tests, and signed build-provenance attestations on every release. + +How findings are triaged, and how to file and document an exception for a false +positive, is described in the +[security tooling runbook](docs/security_tooling.md). diff --git a/docs/agent-context/workflows.md b/docs/agent-context/workflows.md index 104521cb..23a67379 100644 --- a/docs/agent-context/workflows.md +++ b/docs/agent-context/workflows.md @@ -10,7 +10,7 @@ make test # pytest --cov=contextweaver --cov-report=term-missing -q make example # run all example scripts (includes architectures) make architectures # run reference architecture scripts under examples/architectures/ make demo # python -m contextweaver demo -make ci # fmt + lint + type + test + drift-check + module-size-check + doc-snippets-check + readme-version-check + example + demo +make ci # fmt + lint + type + test + drift-check + module-size-check + doc-snippets-check + readme-version-check + security-policy-check + example + demo make ci-full # make ci + floor-deps + tool-smoke (the two isolated-env CI jobs; #710) make floor-deps # prove declared dependency floors resolve + pass tests (mirrors the floor-deps CI job; needs uv; #710) make tool-smoke # build the wheel + run the entry point via uvx/pipx (mirrors the Linux tool-run-smoke CI job; needs uv/pipx; #710) @@ -27,6 +27,7 @@ make sweep-scoring # weight sweep for ScoringConfig (#214); writes benchmarks make context-rot # render context-rot demo JSON + docs/assets/context_rot.svg (#349) make context-rot-check # verify context_rot.svg matches its committed JSON (gating CI step; exits non-zero on drift) make readme-version-check # verify README package/comparison/roadmap refs and Python classifiers match sources (gating CI step; #347/#473/#531) +make security-policy-check # verify SECURITY.md supported series + relative links match pyproject.toml (gating CI step; #691) make llms # regenerate llms.txt and llms-full.txt from canonical docs make llms-check # verify llms.txt and llms-full.txt are up to date (gating CI step; exits non-zero on drift) make gateway-scorecard-check # verify gateway scorecard Markdown matches its committed JSON (gating CI step) @@ -40,7 +41,8 @@ make weaver-conformance # round-trip + JSON-Schema validate the weaver-spec ada > offline: the consolidated generated-artifact drift gate `make drift-check` > (#522 — schemas, scorecards, recorded demos, llms.txt, context-rot SVG, and > the public-API manifest #518), plus `make module-size-check` (#456), -> `make doc-snippets-check` (#526), and `make readme-version-check` (#347/#473/#531). +> `make doc-snippets-check` (#526), `make readme-version-check` (#347/#473/#531), +> and `make security-policy-check` (#691). > The individual `*-check` targets still exist for granular use, but you no > longer need to remember to run them separately before a PR. > diff --git a/docs/security_tooling.md b/docs/security_tooling.md new file mode 100644 index 00000000..76c946a6 --- /dev/null +++ b/docs/security_tooling.md @@ -0,0 +1,96 @@ +# Security Tooling & Exception Process + +This page documents the automated security tooling that runs in CI, how +findings are triaged, and the process for filing and recording an **exception** +when a finding is a false positive or an accepted risk. It is the runbook +referenced from [`SECURITY.md`](https://github.com/dgenio/contextweaver/blob/main/SECURITY.md) +and from the security workflows themselves (issue #692, umbrella #443). + +The goal is that scanner noise never silently erodes the gate: every +suppression is explicit, attributed, time-bounded, and linked to a tracking +issue. + +## Tooling overview + +| Tool | Workflow | Gating? | Surfaces findings in | +|------|----------|---------|----------------------| +| CodeQL | `.github/workflows/codeql.yml` | No (advisory — alerts surface in the Security tab; they do not fail the PR check by default) | Security tab → Code scanning | +| OpenSSF Scorecard | `.github/workflows/ossf-scorecard.yml` | No (informational) | Security tab + README badge | +| pip-audit (core) | `.github/workflows/pip-audit.yml` | **Yes** | Workflow logs / job summary | +| pip-audit (dev extra) | `.github/workflows/pip-audit.yml` | No (report-only) | Workflow logs / job summary | +| Dependabot | `.github/dependabot.yml` | No (opens PRs) | Pull requests | +| Release integrity | `.github/workflows/publish.yml` | **Yes** (blocks publish) | Release run logs | + +The **core** dependency set is gating because contextweaver is designed to sit +in the data path between agents and tools. The **dev/test** extra pulls a large +transitive tree (`crewai`, `mem0ai`, `fastmcp`, `langgraph`, `langchain-core`) +and is report-only so the gate stays signal-rich. + +## Ownership and SLA + +- **Owner:** the repository maintainers (see `CODEOWNERS`/`GOVERNANCE` once + published; until then, `@dgenio`). +- **Triage SLA:** new gating findings are triaged within **7 days**, matching + the vulnerability-report triage target in `SECURITY.md`. +- **Fix SLA:** critical/high within **30 days**; medium/low best-effort on the + next scheduled release. + +## Triage workflow + +1. **Confirm.** Reproduce the finding from the workflow logs or the Security + tab. Note the advisory id (e.g. `GHSA-xxxx` / `PYSEC-xxxx`) or the CodeQL + rule id. +2. **Classify.** True positive, false positive, or accepted risk. +3. **Act:** + - *True positive* → open a fix (bump the dependency, patch the code) and + reference the advisory in the PR. + - *False positive / accepted risk* → file an exception (below). + +## Filing an exception + +Do **not** broaden a gate or delete a check to silence a finding. Instead: + +### pip-audit (dependency advisories) + +Add an explicit, commented ignore in `.github/workflows/pip-audit.yml`: + +```yaml +- name: Audit installed environment (gating) + run: | + pip-audit --progress-spinner off \ + --ignore-vuln GHSA-xxxx-xxxx-xxxx # ; tracked in # +``` + +Each `--ignore-vuln` entry **must** carry an inline comment with the reason and +a tracking issue. Entries are reviewed whenever the dependency changes and +removed once the advisory no longer applies. + +### CodeQL (code scanning alerts) + +Prefer fixing the code. When a finding is a genuine false positive, dismiss the +alert in the Security tab with reason **"False positive"** or **"Used in +tests"** and a justification comment. For a systemic pattern, narrow it with a +committed CodeQL config rather than dismissing alerts one by one. + +### OpenSSF Scorecard + +Scorecard is informational. Address the cheap, high-value checks first +(token-minimal workflow permissions, branch protection, pinned dependencies). +Document any check that is intentionally not satisfied here with a short +rationale. + +## Exception register + +Record active exceptions here so they are visible in one place and can be +audited. Empty until the first exception is filed. + +| Date | Tool | Finding id | Reason | Tracking issue | Review by | +|------|------|------------|--------|----------------|-----------| +| — | — | — | — | — | — | + +## OpenSSF Best Practices badge + +Applying for the [OpenSSF Best Practices badge](https://www.bestpractices.dev/) +is a tracked manual step (issue #552): register the project, complete the +questionnaire, then add the awarded badge to `README.md`. The automated +**Scorecard** badge already surfaces continuously from the workflow above. diff --git a/llms-full.txt b/llms-full.txt index ae2f8ec5..3565a867 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -39,6 +39,7 @@ [![PyPI version](https://img.shields.io/pypi/v/contextweaver.svg)](https://pypi.org/project/contextweaver/) [![Python versions](https://img.shields.io/pypi/pyversions/contextweaver.svg)](https://pypi.org/project/contextweaver/) [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) +[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/dgenio/contextweaver/badge)](https://scorecard.dev/viewer/?uri=github.com/dgenio/contextweaver) [![Docs](https://img.shields.io/badge/docs-mkdocs--material-blue.svg)](https://dgenio.github.io/contextweaver) [![GitHub Discussions](https://img.shields.io/github/discussions/dgenio/contextweaver)](https://github.com/dgenio/contextweaver/discussions) @@ -3440,7 +3441,7 @@ make test # pytest --cov=contextweaver --cov-report=term-missing -q make example # run all example scripts (includes architectures) make architectures # run reference architecture scripts under examples/architectures/ make demo # python -m contextweaver demo -make ci # fmt + lint + type + test + drift-check + module-size-check + doc-snippets-check + readme-version-check + example + demo +make ci # fmt + lint + type + test + drift-check + module-size-check + doc-snippets-check + readme-version-check + security-policy-check + example + demo make ci-full # make ci + floor-deps + tool-smoke (the two isolated-env CI jobs; #710) make floor-deps # prove declared dependency floors resolve + pass tests (mirrors the floor-deps CI job; needs uv; #710) make tool-smoke # build the wheel + run the entry point via uvx/pipx (mirrors the Linux tool-run-smoke CI job; needs uv/pipx; #710) @@ -3457,6 +3458,7 @@ make sweep-scoring # weight sweep for ScoringConfig (#214); writes benchmarks make context-rot # render context-rot demo JSON + docs/assets/context_rot.svg (#349) make context-rot-check # verify context_rot.svg matches its committed JSON (gating CI step; exits non-zero on drift) make readme-version-check # verify README package/comparison/roadmap refs and Python classifiers match sources (gating CI step; #347/#473/#531) +make security-policy-check # verify SECURITY.md supported series + relative links match pyproject.toml (gating CI step; #691) make llms # regenerate llms.txt and llms-full.txt from canonical docs make llms-check # verify llms.txt and llms-full.txt are up to date (gating CI step; exits non-zero on drift) make gateway-scorecard-check # verify gateway scorecard Markdown matches its committed JSON (gating CI step) @@ -3470,7 +3472,8 @@ make weaver-conformance # round-trip + JSON-Schema validate the weaver-spec ada > offline: the consolidated generated-artifact drift gate `make drift-check` > (#522 — schemas, scorecards, recorded demos, llms.txt, context-rot SVG, and > the public-API manifest #518), plus `make module-size-check` (#456), -> `make doc-snippets-check` (#526), and `make readme-version-check` (#347/#473/#531). +> `make doc-snippets-check` (#526), `make readme-version-check` (#347/#473/#531), +> and `make security-policy-check` (#691). > The individual `*-check` targets still exist for granular use, but you no > longer need to remember to run them separately before a PR. > diff --git a/mkdocs.yml b/mkdocs.yml index bb092be9..48a76faf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -94,6 +94,7 @@ nav: - Gateway Spec: gateway_spec.md - HTTP Sidecar: sidecar.md - Security Model: security_model.md + - Security Tooling: security_tooling.md - Guides: - Runtime Loop: guide_agent_loop.md - MCP Integration: integration_mcp.md diff --git a/pyproject.toml b/pyproject.toml index a20b8dfe..4b09b289 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -470,6 +470,22 @@ ignore_missing_imports = true module = "mcp.*" ignore_missing_imports = true +# numpy is a transitive [dev] dependency (chromadb / langgraph / crewai), not a +# direct one. numpy>=2.5 ships ``.pyi`` stubs that use PEP 695 ``type`` +# statements, which mypy rejects under this project's ``python_version = "3.10"`` +# target ("Type statement is only supported in Python 3.12 and greater") — a +# hard parse error that aborts the whole run even though no contextweaver code +# is numpy-typed. ``follow_imports = skip`` makes mypy treat numpy as ``Any``; +# ``follow_imports_for_stubs`` is required for the skip to apply to numpy's +# ``.pyi`` stubs (without it mypy still parses them and re-hits the error). +# Decouples the type gate from numpy's stub syntax. Revisit once a committed +# lockfile (#619) pins the dev toolchain. +[[tool.mypy.overrides]] +module = ["numpy", "numpy.*"] +follow_imports = "skip" +follow_imports_for_stubs = true +ignore_missing_imports = true + [[tool.mypy.overrides]] module = "jsonschema.*" ignore_missing_imports = true diff --git a/scripts/check_readme_version.py b/scripts/check_readme_version.py index eaa48bf5..d7dcb5bd 100644 --- a/scripts/check_readme_version.py +++ b/scripts/check_readme_version.py @@ -157,11 +157,19 @@ def _version_key(version: str) -> tuple[int, ...]: def main(argv: Sequence[str] | None = None) -> int: - """Check README version references and Python classifiers.""" - # No flags today; argv is accepted for symmetry with the other scripts and - # to keep the call shape stable for tests. - _ = argv + """Check README version references and Python classifiers. + + With ``--print-version`` the package version from ``pyproject.toml`` is + printed to stdout and no checks run. The release-integrity gate in + ``publish.yml`` (issue #468) uses this so the tag-vs-version comparison + reads the version through the same single source of truth as the drift + guard, rather than re-parsing ``pyproject.toml`` in shell. + """ + args = list(sys.argv[1:] if argv is None else argv) version = read_pyproject_version(DEFAULT_PYPROJECT) + if "--print-version" in args: + print(version) + return 0 problems = find_drift(version, DEFAULT_README.read_text(encoding="utf-8")) problems.extend( find_classifier_drift( diff --git a/scripts/check_security_policy.py b/scripts/check_security_policy.py new file mode 100644 index 00000000..1aa28180 --- /dev/null +++ b/scripts/check_security_policy.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Fail when SECURITY.md drifts from its sources of truth (#691, umbrella #443). + +``SECURITY.md`` carried a hand-maintained "Supported Versions" table that +silently lagged the released version (it claimed ``0.14.x`` while the package +had moved to ``0.16.0``). A stale security policy is worse than an absent one: +it tells reporters and adopters the wrong thing about which releases receive +fixes. This guard makes ``pyproject.toml`` the single source of truth for the +supported minor series and fails CI on drift. + +It also performs the policy *link check* (#691 acceptance criteria): every +repo-relative document linked from ``SECURITY.md`` must exist on disk, so the +policy never points reporters at a moved or deleted page (e.g. the +``docs/security_model.md`` deployment-boundary guide or the +``docs/security_tooling.md`` exception runbook). + +Checks: + +1. The "Supported Versions" table marks the current ``MAJOR.MINOR`` series + (derived from ``pyproject.toml``) as supported. +2. The table does not still mark a *different* minor as the supported series. +3. Every repo-relative Markdown link target in ``SECURITY.md`` resolves to an + existing file. + +Usage:: + + python scripts/check_security_policy.py # exits non-zero on drift + +Stdlib-only — no contextweaver import and no ``tomllib`` (unavailable on +Python 3.10) — so it runs before the package is installed, matching the +``scripts/check_readme_version.py`` convention. +""" + +from __future__ import annotations + +import re +import sys +from collections.abc import Sequence +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_PYPROJECT = REPO_ROOT / "pyproject.toml" +DEFAULT_SECURITY = REPO_ROOT / "SECURITY.md" + +# The ``[project]`` table body: from the ``[project]`` header up to the next +# top-level table header (``[...]`` at column 0) or end of file. +_PROJECT_TABLE_RE = re.compile(r"^\[project\][^\n]*\n(.*?)(?=^\[|\Z)", re.MULTILINE | re.DOTALL) +_VERSION_RE = re.compile(r'^version = "([^"]+)"', re.MULTILINE) +# A supported-version table row: ``| 0.16.x | Yes |`` (the ``.x`` is optional so +# both ``0.16`` and ``0.16.x`` forms match). Captures the minor series and the +# Yes/No support flag. +_SUPPORT_ROW_RE = re.compile( + r"^\|\s*(\d+\.\d+)(?:\.x)?\s*\|\s*(Yes|No)\s*\|", + re.MULTILINE | re.IGNORECASE, +) +# A repo-relative Markdown link target, e.g. ``[text](docs/security_model.md)``. +# Anchors (``#frag``) and absolute URLs (``http://``, ``mailto:``) are skipped. +_MD_LINK_RE = re.compile(r"\]\(([^)]+)\)") + + +def read_pyproject_version(pyproject: Path) -> str: + """Return the ``[project]`` version string from *pyproject*.""" + text = pyproject.read_text(encoding="utf-8") + table = _PROJECT_TABLE_RE.search(text) + if table is None: + raise ValueError(f"could not find a [project] table in {pyproject}") + match = _VERSION_RE.search(table.group(1)) + if not match: + raise ValueError(f"could not find a version in the [project] table of {pyproject}") + return match.group(1) + + +def current_minor(version: str) -> str: + """Return the ``MAJOR.MINOR`` series for *version* (``0.16.0`` -> ``0.16``).""" + parts = version.split(".") + if len(parts) < 2: + raise ValueError(f"version {version!r} is not MAJOR.MINOR.PATCH") + return f"{parts[0]}.{parts[1]}" + + +def find_supported_drift(version: str, security_text: str) -> list[str]: + """Return drift messages for the Supported Versions table vs *version*.""" + problems: list[str] = [] + minor = current_minor(version) + rows = _SUPPORT_ROW_RE.findall(security_text) + if not rows: + problems.append("SECURITY.md has no recognisable 'Supported Versions' table rows.") + return problems + + supported = {series for series, flag in rows if flag.lower() == "yes"} + if minor not in supported: + problems.append( + f"SECURITY.md does not mark the current series '{minor}.x' as supported " + f"(package version is {version}); supported rows are {sorted(supported)}." + ) + # A different minor still flagged as the supported series is stale: the + # policy supports only the latest minor (per SECURITY.md's own preamble). + stale = sorted(s for s in supported if s != minor) + if stale: + problems.append( + f"SECURITY.md still marks {stale} as supported; only the current " + f"series '{minor}.x' should be 'Yes' under the latest-minor policy." + ) + return problems + + +def find_broken_links(security_path: Path) -> list[str]: + """Return messages for repo-relative links in *security_path* that 404. + + Only true *repo-relative* links are validated. A link is rejected — not + silently passed — when it is absolute (``/etc/passwd``) or escapes the repo + root via ``..`` traversal (``../../outside.md``): such a target is not + repo-relative, so even if the resolved path happens to exist on the runner + it must not satisfy the gate (review feedback on the initial gate). + """ + problems: list[str] = [] + text = security_path.read_text(encoding="utf-8") + base = security_path.resolve().parent + for target in _MD_LINK_RE.findall(text): + link = target.strip() + # Skip absolute URLs, anchors, and mail links — only repo-relative + # filesystem paths are verifiable here. + if link.startswith(("http://", "https://", "mailto:", "#")): + continue + path_part = link.split("#", 1)[0] + if not path_part: + continue + if path_part.startswith("/"): + problems.append( + f"SECURITY.md links to '{path_part}', which is an absolute path, " + "not a repo-relative one." + ) + continue + resolved = (base / path_part).resolve() + if base not in resolved.parents and resolved != base: + problems.append( + f"SECURITY.md links to '{path_part}', which escapes the repository root." + ) + continue + if not resolved.exists(): + problems.append(f"SECURITY.md links to '{path_part}', which does not exist.") + return problems + + +def main(argv: Sequence[str] | None = None) -> int: + """Check SECURITY.md supported-version table and relative links.""" + _ = argv + version = read_pyproject_version(DEFAULT_PYPROJECT) + problems = find_supported_drift(version, DEFAULT_SECURITY.read_text(encoding="utf-8")) + problems.extend(find_broken_links(DEFAULT_SECURITY)) + if problems: + print( + f"error: SECURITY.md is out of sync with its sources of truth " + f"(pyproject is {version}):", + file=sys.stderr, + ) + for problem in problems: + print(f" - {problem}", file=sys.stderr) + print( + "Update SECURITY.md (or the source of truth) and re-run.", + file=sys.stderr, + ) + return 1 + print(f"SECURITY.md supported-version table and links are in sync ({version}).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_check_readme_version.py b/tests/test_check_readme_version.py index 86f65458..132e416e 100644 --- a/tests/test_check_readme_version.py +++ b/tests/test_check_readme_version.py @@ -16,6 +16,8 @@ import sys from pathlib import Path +import pytest + sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) import check_readme_version # noqa: E402 (import after sys.path manipulation) @@ -140,6 +142,21 @@ def test_find_drift_flags_current_marker_without_explicit_version() -> None: assert any("does not name" in p for p in problems) +def test_print_version_outputs_bare_pyproject_version( + capsys: pytest.CaptureFixture[str], +) -> None: + """``--print-version`` prints exactly the pyproject version and runs no checks. + + The release-integrity gate in ``publish.yml`` (#468) compares the release tag + against this output (``[ "$tag" != "v$version" ]``), so it must be the bare + version with no surrounding text — a trailing line or banner would silently + break the tag-vs-version comparison. + """ + expected = check_readme_version.read_pyproject_version(check_readme_version.DEFAULT_PYPROJECT) + assert check_readme_version.main(["--print-version"]) == 0 + assert capsys.readouterr().out == f"{expected}\n" + + def test_repo_readme_is_in_sync() -> None: """The production gate: the committed README matches pyproject.""" assert check_readme_version.main() == 0 diff --git a/tests/test_check_security_policy.py b/tests/test_check_security_policy.py new file mode 100644 index 00000000..276ea01d --- /dev/null +++ b/tests/test_check_security_policy.py @@ -0,0 +1,105 @@ +"""Tests for the SECURITY.md drift guard (#691). + +The guard makes ``pyproject.toml`` the single source of truth for the supported +minor series advertised in ``SECURITY.md`` and verifies that every +repo-relative link the policy references still resolves. These unit tests pin +the detection logic against synthetic fixtures and assert the real repository +is currently in sync. + +The guard lives under ``scripts/``, so it is added to ``sys.path`` the same way +:mod:`tests.test_check_readme_version` does. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) + +import check_security_policy # noqa: E402 (import after sys.path manipulation) + + +def test_read_pyproject_version_scoped_to_project_table(tmp_path: Path) -> None: + """A ``version`` in another table must not shadow the [project] version.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + '[build-system]\nrequires = ["setuptools"]\nversion = "9.9.9"\n\n' + '[project]\nname = "x"\nversion = "1.2.3"\n', + encoding="utf-8", + ) + assert check_security_policy.read_pyproject_version(pyproject) == "1.2.3" + + +def test_current_minor() -> None: + """The MAJOR.MINOR series is derived from a full version string.""" + assert check_security_policy.current_minor("0.16.0") == "0.16" + assert check_security_policy.current_minor("1.2.3rc1") == "1.2" + + +def test_find_supported_drift_in_sync() -> None: + """No drift when the table marks the current minor (``.x`` form) supported.""" + text = "| Version | Supported |\n|--|--|\n| 0.16.x | Yes |\n| < 0.16 | No |\n" + assert check_security_policy.find_supported_drift("0.16.0", text) == [] + + +def test_find_supported_drift_flags_stale_minor() -> None: + """A stale supported series (e.g. 0.14.x while package is 0.16.0) is flagged.""" + text = "| Version | Supported |\n|--|--|\n| 0.14.x | Yes |\n| < 0.14 | No |\n" + problems = check_security_policy.find_supported_drift("0.16.0", text) + assert len(problems) == 2 # current not supported + stale series still 'Yes' + assert any("0.16" in p for p in problems) + assert any("0.14" in p for p in problems) + + +def test_find_supported_drift_flags_missing_table() -> None: + """A SECURITY.md with no recognisable support rows is flagged.""" + problems = check_security_policy.find_supported_drift("0.16.0", "no table here") + assert problems == ["SECURITY.md has no recognisable 'Supported Versions' table rows."] + + +def test_find_broken_links_flags_missing_target(tmp_path: Path) -> None: + """A repo-relative link to a nonexistent file is flagged; URLs are skipped.""" + security = tmp_path / "SECURITY.md" + (tmp_path / "exists.md").write_text("ok", encoding="utf-8") + security.write_text( + "See [present](exists.md) and [absent](docs/missing.md).\n" + "External [site](https://example.com) and [anchor](#scope) are skipped.\n", + encoding="utf-8", + ) + problems = check_security_policy.find_broken_links(security) + assert problems == ["SECURITY.md links to 'docs/missing.md', which does not exist."] + + +def test_find_broken_links_resolves_anchor_targets(tmp_path: Path) -> None: + """A link with a ``#fragment`` resolves against the file part only.""" + security = tmp_path / "SECURITY.md" + (tmp_path / "page.md").write_text("ok", encoding="utf-8") + security.write_text("[x](page.md#section)\n", encoding="utf-8") + assert check_security_policy.find_broken_links(security) == [] + + +def test_find_broken_links_rejects_absolute_path(tmp_path: Path) -> None: + """An absolute-path link is rejected even though the target exists.""" + security = tmp_path / "SECURITY.md" + security.write_text("[passwd](/etc/hostname)\n", encoding="utf-8") + problems = check_security_policy.find_broken_links(security) + assert len(problems) == 1 + assert "absolute path" in problems[0] + + +def test_find_broken_links_rejects_parent_traversal(tmp_path: Path) -> None: + """A ``..`` link that escapes the repo root is rejected, not silently passed.""" + base = tmp_path / "repo" + base.mkdir() + (tmp_path / "outside.md").write_text("ok", encoding="utf-8") # exists, but outside + security = base / "SECURITY.md" + security.write_text("[escape](../outside.md)\n", encoding="utf-8") + problems = check_security_policy.find_broken_links(security) + assert len(problems) == 1 + assert "escapes the repository root" in problems[0] + + +def test_repository_security_policy_is_in_sync() -> None: + """The live SECURITY.md must match the package version and have no dead links.""" + assert check_security_policy.main([]) == 0