Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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: ["*"]
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down
47 changes: 47 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -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 }}
33 changes: 19 additions & 14 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,19 @@ 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
runs-on: ubuntu-latest
outputs:
version: ${{ steps.check.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
ref: ${{ inputs.tag }}
- id: check
Expand Down Expand Up @@ -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
Expand All @@ -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/*
Expand All @@ -96,15 +101,15 @@ 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
run: |
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/*
Expand All @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 <github-url> (secfoo shells out to
# `git clone --depth 1`).
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
140 changes: 140 additions & 0 deletions tests/test_property_parsers.py
Original file line number Diff line number Diff line change
@@ -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="<test>")
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="<test>")
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)
Loading