Skip to content
31 changes: 31 additions & 0 deletions .github/bandit-baseline.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"results": [
{
"code": "34 run_cmd,\n35 shell=True,\n36 capture_output=True,\n37 text=True,\n38 cwd=cwd,\n39 timeout=300,\n40 )\n41 output = {\n42 \"exit_code\": proc.returncode,\n43 \"stdout\": proc.stdout,\n",
"col_offset": 19,
"end_col_offset": 13,
"filename": "src/specify_cli/workflows/steps/shell/__init__.py",
"issue_confidence": "HIGH",
"issue_cwe": {
"id": 78,
"link": "https://cwe.mitre.org/data/definitions/78.html"
},
"issue_severity": "HIGH",
"issue_text": "subprocess call with shell=True identified, security issue.",
"line_number": 35,
"line_range": [
33,
34,
35,
36,
37,
38,
39,
40
],
"more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b602_subprocess_popen_with_shell_equals_true.html",
"test_id": "B602",
"test_name": "subprocess_popen_with_shell_equals_true"
}
]
}
56 changes: 56 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: Security Audit

permissions:
contents: read

on:
push:
branches: ["main"]
pull_request:
schedule:
- cron: "17 4 * * 1"
workflow_dispatch:

jobs:
dependency-audit:
name: Dependency audit (${{ matrix.os }}, Python ${{ matrix.python-version }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ["3.11", "3.12", "3.13"]
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: ${{ matrix.python-version }}

- name: Run pip-audit
run: |
uv pip compile pyproject.toml --extra test --python-version "${{ matrix.python-version }}" --generate-hashes --quiet --output-file "${{ runner.temp }}/spec-kit-audit-requirements.txt"
Comment thread
PascalThuet marked this conversation as resolved.
Outdated
uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes -r "${{ runner.temp }}/spec-kit-audit-requirements.txt" --progress-spinner off

Comment thread
mnriem marked this conversation as resolved.
static-analysis:
name: Static analysis
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0

- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.13"

- name: Run Bandit
run: uvx --from bandit==1.9.4 bandit -r src -lll --baseline .github/bandit-baseline.json
Comment thread
PascalThuet marked this conversation as resolved.
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ uv run python -m pytest tests/test_agent_config_consistency.py -q

Run this when you change agent metadata, context update scripts, or integration wiring.

#### Security checks

```bash
uv pip compile pyproject.toml --extra test --generate-hashes --quiet --output-file spec-kit-audit-requirements.txt
uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes -r spec-kit-audit-requirements.txt --progress-spinner off
Comment thread
PascalThuet marked this conversation as resolved.
Outdated
uvx --from bandit==1.9.4 bandit -r src -lll --baseline .github/bandit-baseline.json
```

Run these before changing dependency metadata, workflow execution code, subprocess usage, or security-sensitive paths. The dependency audit resolves the runtime and `test` extra dependency set used by CI and contributors. CI runs the dependency audit across the supported Python and OS matrix; locally, run these commands from the environment you want to reproduce.

### Manual testing

#### Testing setup
Expand Down
17 changes: 14 additions & 3 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,13 +397,24 @@ def callback(
console.print()

def run_command(cmd: list[str], check_return: bool = True, capture: bool = False, shell: bool = False) -> Optional[str]:
"""Run a shell command and optionally capture output."""
"""Run a command without invoking a shell and optionally capture output."""
if shell:
raise ValueError(
"run_command does not support shell=True; use a reviewed "
"subprocess.run call for shell-specific behavior."
)
Comment thread
PascalThuet marked this conversation as resolved.
Outdated

try:
if capture:
result = subprocess.run(cmd, check=check_return, capture_output=True, text=True, shell=shell)
result = subprocess.run(
cmd,
check=check_return,
capture_output=True,
text=True,
)
return result.stdout.strip()
else:
subprocess.run(cmd, check=check_return, shell=shell)
subprocess.run(cmd, check=check_return)
return None
except subprocess.CalledProcessError as e:
if check_return:
Expand Down
151 changes: 151 additions & 0 deletions tests/test_security_workflow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""Static checks for the GitHub Actions security workflow."""

from __future__ import annotations

import json
import re
from pathlib import Path

import pytest
import yaml


REPO_ROOT = Path(__file__).resolve().parent.parent
SECURITY_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "security.yml"
CONTRIBUTING = REPO_ROOT / "CONTRIBUTING.md"
BANDIT_BASELINE = REPO_ROOT / ".github" / "bandit-baseline.json"

WORKFLOW_AUDIT_REQUIREMENTS = '"${{ runner.temp }}/spec-kit-audit-requirements.txt"'
LOCAL_AUDIT_REQUIREMENTS = "spec-kit-audit-requirements.txt"
WORKFLOW_COMPILE_TEST_EXTRA_DEPS = (
"uv pip compile pyproject.toml --extra test "
'--python-version "${{ matrix.python-version }}" --generate-hashes --quiet '
f"--output-file {WORKFLOW_AUDIT_REQUIREMENTS}"
)
LOCAL_COMPILE_TEST_EXTRA_DEPS = (
"uv pip compile pyproject.toml --extra test --generate-hashes --quiet "
f"--output-file {LOCAL_AUDIT_REQUIREMENTS}"
)
WORKFLOW_PIP_AUDIT = (
"uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes "
f"-r {WORKFLOW_AUDIT_REQUIREMENTS} --progress-spinner off"
)
LOCAL_PIP_AUDIT = (
"uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes "
f"-r {LOCAL_AUDIT_REQUIREMENTS} --progress-spinner off"
)
BANDIT = (
"uvx --from bandit==1.9.4 bandit -r src -lll "
"--baseline .github/bandit-baseline.json"
)


def _load_security_workflow() -> dict:
return yaml.safe_load(SECURITY_WORKFLOW.read_text(encoding="utf-8"))


def _step_run(job_name: str, step_name: str) -> str:
workflow = _load_security_workflow()
for step in workflow["jobs"][job_name]["steps"]:
if step.get("name") == step_name:
return step["run"]
raise AssertionError(f"Step {step_name!r} not found in job {job_name!r}.")


class TestSecurityWorkflow:
"""Guard the security workflow against review-feedback regressions."""

def test_dependency_audit_compiles_test_extra_requirements(self):
run = _step_run("dependency-audit", "Run pip-audit")

assert WORKFLOW_COMPILE_TEST_EXTRA_DEPS in run
assert WORKFLOW_PIP_AUDIT in run
assert "--generate-hashes" in run
assert "--require-hashes" in run
assert "--disable-pip" in run
assert "${{ runner.temp }}" in run
assert "uv export" not in run
assert "--frozen" not in run
assert "--locked" not in run
assert "uv.lock" not in run
assert "/tmp/" not in run
assert "uvx pip-audit ." not in run

def test_dependency_audit_runs_supported_python_os_matrix(self):
workflow = _load_security_workflow()
matrix = workflow["jobs"]["dependency-audit"]["strategy"]["matrix"]

assert matrix["os"] == ["ubuntu-latest", "windows-latest"]
assert matrix["python-version"] == ["3.11", "3.12", "3.13"]
assert workflow["jobs"]["dependency-audit"]["runs-on"] == "${{ matrix.os }}"

Comment thread
PascalThuet marked this conversation as resolved.
def test_security_tools_are_pinned(self):
workflow_text = SECURITY_WORKFLOW.read_text(encoding="utf-8")

assert WORKFLOW_PIP_AUDIT in workflow_text
assert BANDIT in workflow_text
assert re.search(r"\buvx\s+pip-audit\b", workflow_text) is None
assert re.search(r"\buvx\s+bandit\b", workflow_text) is None

def test_actions_are_pinned_to_full_commit_shas(self):
workflow = _load_security_workflow()
uses_refs = [
step["uses"]
for job in workflow["jobs"].values()
for step in job["steps"]
if "uses" in step
]

assert uses_refs
for uses_ref in uses_refs:
assert re.search(r"@[0-9a-f]{40}$", uses_ref), uses_ref
assert re.search(r"@v\d+", uses_ref) is None

def test_bandit_does_not_globally_skip_b602(self):
run = _step_run("static-analysis", "Run Bandit")
workflow_text = SECURITY_WORKFLOW.read_text(encoding="utf-8")

assert run == BANDIT
assert "--skip" not in run
assert "--skip B602" not in workflow_text
assert "--baseline .github/bandit-baseline.json" in run

def test_bandit_baseline_only_ignores_shell_step_b602(self):
baseline = json.loads(BANDIT_BASELINE.read_text(encoding="utf-8"))
results = baseline["results"]

assert len(results) == 1
assert results[0]["test_id"] == "B602"
assert (
results[0]["filename"]
== "src/specify_cli/workflows/steps/shell/__init__.py"
)

def test_bandit_nosec_is_not_suppressed_in_source(self):
nosec_lines = []
for path in (REPO_ROOT / "src").rglob("*.py"):
for line_number, line in enumerate(
path.read_text(encoding="utf-8").splitlines(),
start=1,
):
if re.search(r"#\s*nosec\b", line, flags=re.IGNORECASE):
nosec_lines.append(f"{path.relative_to(REPO_ROOT)}:{line_number}")

assert nosec_lines == []

def test_run_command_rejects_shell_true(self):
from specify_cli import run_command

with pytest.raises(ValueError, match="shell=True"):
run_command(["echo", "hello"], shell=True)

def test_contributing_documents_security_commands(self):
contributing_text = CONTRIBUTING.read_text(encoding="utf-8")

assert LOCAL_COMPILE_TEST_EXTRA_DEPS in contributing_text
assert LOCAL_PIP_AUDIT in contributing_text
assert BANDIT in contributing_text
assert "/tmp/" not in contributing_text
assert "uv export" not in contributing_text
assert "--frozen" not in contributing_text
assert "--locked" not in contributing_text
Loading