From 95e73e0a0f1cfe1c802ddf0822921388f3d731f4 Mon Sep 17 00:00:00 2001 From: aitechnology <40792067+vsn411@users.noreply.github.com> Date: Wed, 11 Nov 2026 21:41:03 +0000 Subject: [PATCH] Harden supply chain: pin actions, Dependabot, CodeQL workflow, property tests Driven by the OpenSSF Scorecard results (4.6/10): - Token-Permissions (0): release.yml had no top-level permissions block, so every job without its own grant ran with full write access. Default to contents: read; the release and GHCR jobs keep their scoped writes. - Pinned-Dependencies (1): every action in ci.yml and release.yml is now pinned to a commit SHA with the version in a comment, and the Docker base image to its digest. 13 GitHub-owned and 3 third-party actions were floating on major tags. - Dependency-Update-Tool (0): .github/dependabot.yml for github-actions, pip, docker and npm, weekly and grouped per ecosystem. - SAST (4): CodeQL default setup scanned PRs only, leaving 18 of the last 30 commits unchecked. codeql.yml runs on push to main, PRs and weekly for python, javascript-typescript and actions. - Fuzzing (0): Hypothesis property tests for the severity/risk-rating report parsers, skill frontmatter parsing and severity normalisation, covering round-trips and never-raises-on-garbage. - Branch-Protection: CODEOWNERS so code-owner review can be required once a second owner is listed. --- .github/CODEOWNERS | 4 + .github/dependabot.yml | 37 +++++++++ .github/workflows/ci.yml | 4 +- .github/workflows/codeql.yml | 47 +++++++++++ .github/workflows/release.yml | 33 ++++---- Dockerfile | 2 +- pyproject.toml | 2 +- tests/test_property_parsers.py | 140 +++++++++++++++++++++++++++++++++ 8 files changed, 251 insertions(+), 18 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 tests/test_property_parsers.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..73a1e58 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,4 @@ +# Reviewers automatically requested on every PR. Add a second owner here +# before enabling "require code owner review" in the main ruleset, or the +# owner's own PRs can never be approved. +* @vsn411 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6a544e6 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,37 @@ +# Keeps the pinned GitHub Actions SHAs, the Docker base image digest, and +# the Python / npm dependencies current. Grouped so a week's worth of +# bumps arrives as one PR per ecosystem instead of a dozen. +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + groups: + actions: + patterns: ["*"] + + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + day: monday + groups: + python: + patterns: ["*"] + + - package-ecosystem: docker + directory: / + schedule: + interval: weekly + day: monday + + - package-ecosystem: npm + directory: /npm + schedule: + interval: weekly + day: monday + groups: + npm: + patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c672c4a..e5d826d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,9 +21,9 @@ jobs: matrix: python-version: ["3.10", "3.12"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..c022e15 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,47 @@ +name: CodeQL + +# Static analysis on every push to main and every PR, plus a weekly sweep +# for newly published queries. Replaces GitHub's "default setup", which +# only scanned PRs and left direct pushes uncovered. +# +# Results land in Security > Code scanning. A default-setup and an +# advanced workflow can't both be enabled, so default setup is turned off +# for this repo. + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "0 5 * * 2" + +permissions: read-all + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + security-events: write + strategy: + fail-fast: false + matrix: + language: [python, javascript-typescript, actions] + + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3 + with: + languages: ${{ matrix.language }} + queries: security-and-quality + + - name: Analyze + uses: github/codeql-action/analyze@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3 + with: + category: /language:${{ matrix.language }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e6136bc..8a7a0ef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,11 @@ on: description: "Tag to release (e.g. v0.3.0) -- must already exist and match src/secfoo/__about__.py" required: true +# Read-only by default; the two jobs that need to write (the GitHub +# Release, the GHCR push) grant themselves exactly that permission. +permissions: + contents: read + jobs: check-version: name: Verify tag matches package version @@ -29,7 +34,7 @@ jobs: outputs: version: ${{ steps.check.outputs.version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: ref: ${{ inputs.tag }} - id: check @@ -64,10 +69,10 @@ jobs: name: win32-x64 runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: ref: ${{ inputs.tag }} - - uses: actions/setup-python@v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - name: Install package + build deps @@ -83,7 +88,7 @@ jobs: else cp dist/binary/secfoo out/secfoo-${{ matrix.name }} fi - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: secfoo-${{ matrix.name }} path: out/* @@ -96,7 +101,7 @@ jobs: permissions: contents: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: path: artifacts - name: Flatten artifacts and compute checksums @@ -104,7 +109,7 @@ jobs: mkdir -p release find artifacts -type f -exec cp {} release/ \; (cd release && sha256sum * > SHA256SUMS) - - uses: softprops/action-gh-release@v2 + - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: tag_name: ${{ inputs.tag }} files: release/* @@ -115,10 +120,10 @@ jobs: needs: [check-version] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: ref: ${{ inputs.tag }} - - uses: actions/setup-python@v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - run: pip install build twine @@ -133,11 +138,11 @@ jobs: needs: [check-version, release] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: path: artifacts - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "20" registry-url: "https://registry.npmjs.org" @@ -177,17 +182,17 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: ref: ${{ inputs.tag }} - - uses: docker/login-action@v3 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Wait for the new version to be installable from PyPI run: sleep 60 - - uses: docker/build-push-action@v6 + - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . push: true diff --git a/Dockerfile b/Dockerfile index 6935fa4..1928219 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ # execute a skill. Without that, this image is still useful standalone for # `secfoo serve` / `secfoo list` / `secfoo show` against a mounted # ~/.secfoo store from a host-run assessment. -FROM python:3.12-slim +FROM python:3.12-slim@sha256:5e2bdb1b0d542d5039cffb8181c65930093949194a65c365dfb1b0fe13a4fa5b # git is required for --target (secfoo shells out to # `git clone --depth 1`). diff --git a/pyproject.toml b/pyproject.toml index 0247037..f677a67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ dependencies = [ ] [project.optional-dependencies] -dev = ["jsonschema>=4.0", "pytest>=8.0", "httpx>=0.27", "ruff>=0.4", "bandit>=1.8", "pip-audit>=2.7", "langgraph>=0.2"] +dev = ["jsonschema>=4.0", "pytest>=8.0", "hypothesis>=6.100", "httpx>=0.27", "ruff>=0.4", "bandit>=1.8", "pip-audit>=2.7", "langgraph>=0.2"] build = ["pyinstaller>=6.0"] # The `api` agent (secfoo/agents/api.py): a LangGraph workflow that calls OpenAI/Anthropic/Gemini # through LiteLLM with an API key instead of driving an installed agent CLI. diff --git a/tests/test_property_parsers.py b/tests/test_property_parsers.py new file mode 100644 index 0000000..303649c --- /dev/null +++ b/tests/test_property_parsers.py @@ -0,0 +1,140 @@ +"""Property-based tests for the report and skill-definition parsers. + +These parsers consume text produced by an LLM (reports) or written by hand +(skill files), so they get fed every shape of input. Hypothesis generates +the inputs; the properties below are what must hold for all of them: +round-tripping, never raising on garbage, and counting exactly what was +put in. +""" + +from __future__ import annotations + +from hypothesis import given, settings +from hypothesis import strategies as st + +from secfoo.report.severity import ( + SeverityCounts, + count_severities, + extract_overall_risk_rating, +) +from secfoo.skills.loader import SkillLoadError, _parse_frontmatter +from secfoo.web.finding_filters import _VALID_SEVERITIES, normalize_severity + +# --------------------------------------------------------------------------- # +# report/severity.py +# --------------------------------------------------------------------------- # + +TIERS = { + "critical": "critical", + "high": "high", + "medium": "medium", + "low": "low", + "info": "info", + "informational": "info", +} + +# Any casing of a recognised tier word, e.g. "HIGH", "High", "hIgH". +tier_word = st.sampled_from(sorted(TIERS)).flatmap( + lambda word: st.lists(st.booleans(), min_size=len(word), max_size=len(word)).map( + lambda caps: "".join(c.upper() if up else c for c, up in zip(word, caps)) + ) +) +# Letters only, so it can never collide with a tier alias by accident. +unknown_word = st.from_regex(r"[A-Za-z]{1,12}", fullmatch=True).filter(lambda w: w.lower() not in TIERS) +# A heading title or a line of filler: anything printable on one line that +# doesn't start a new heading. +one_line = st.text( + alphabet=st.characters(blacklist_categories=("Cs", "Cc"), blacklist_characters="\n\r"), + max_size=40, +).filter(lambda s: not s.lstrip().startswith("###")) + + +@given( + headings=st.lists(st.tuples(st.one_of(tier_word, unknown_word), one_line), max_size=25), + filler=st.lists(one_line, max_size=25), +) +def test_count_severities_counts_exactly_the_recognised_headings(headings, filler): + lines = list(filler) + expected = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0} + for word, title in headings: + lines.insert(len(lines) // 2, f"### [{word}] F: {title}") + tier = TIERS.get(word.lower()) + if tier: + expected[tier] += 1 + counts = count_severities("\n".join(lines)) + assert counts == SeverityCounts(**expected) + assert counts.total == sum(expected.values()) + + +@given(st.text()) +@settings(max_examples=300) +def test_count_severities_never_raises(text): + counts = count_severities(text) + assert counts.total >= 0 + + +@given(word=st.one_of(tier_word, unknown_word), before=st.text(max_size=80), after=st.text(max_size=80)) +def test_overall_risk_rating_round_trips(word, before, after): + # The marker is matched case-insensitively and the rating is lowercased. + report = f"{before}\n**Overall risk rating:** {word}\n{after}" + assert extract_overall_risk_rating(report) == word.lower() + + +@given(st.text()) +def test_overall_risk_rating_never_raises(text): + rating = extract_overall_risk_rating(text) + assert rating is None or rating == rating.lower() + + +# --------------------------------------------------------------------------- # +# skills/loader.py frontmatter +# --------------------------------------------------------------------------- # + +frontmatter_key = st.from_regex(r"[a-z][a-z0-9_]{0,15}", fullmatch=True) +# Values may contain ':' (the parser splits on the first one only) but not +# newlines or '---', which would end the block. +frontmatter_value = ( + st.text( + alphabet=st.characters(blacklist_categories=("Cs", "Cc"), blacklist_characters="\n\r"), + min_size=1, + max_size=40, + ) + .map(str.strip) + .filter(lambda v: v and "---" not in v) +) +body_text = st.text(max_size=200).filter(lambda b: "---" not in b) + + +@given(fields=st.dictionaries(frontmatter_key, frontmatter_value, min_size=1, max_size=8), body=body_text) +def test_frontmatter_round_trips(fields, body): + rendered = "---\n" + "".join(f"{k}: {v}\n" for k, v in fields.items()) + "---\n" + body + parsed_fields, parsed_body = _parse_frontmatter(rendered, source="") + assert parsed_fields == fields + assert parsed_body == body.strip() + + +@given(st.text()) +@settings(max_examples=300) +def test_frontmatter_rejects_garbage_with_skill_load_error_only(text): + try: + fields, body = _parse_frontmatter(text, source="") + except SkillLoadError: + return + assert isinstance(fields, dict) + assert isinstance(body, str) + + +# --------------------------------------------------------------------------- # +# web/finding_filters.py +# --------------------------------------------------------------------------- # + +@given(st.one_of(st.none(), st.text(max_size=30))) +def test_normalize_severity_is_total_and_idempotent(value): + out = normalize_severity(value) + assert out == "" or out in _VALID_SEVERITIES + assert normalize_severity(out) == out + + +@given(st.sampled_from(sorted(_VALID_SEVERITIES) + ["informational"]), st.text(alphabet=" \t", max_size=4)) +def test_normalize_severity_ignores_case_and_padding(word, pad): + assert normalize_severity(pad + word.upper() + pad) == normalize_severity(word)