Compare commits

..

1 Commits

Author SHA1 Message Date
theCyberTech
0cc43b2720 feat: replace advisory pip-audit with blocking vuln process
New vulnerability scan process:
1. Run pip-audit without ignores on every PR
2. Classify vulns as direct or transitive (checks against all monorepo pyproject.toml files)
3. Direct vulns: auto-fix with pip-audit --fix and commit the bump to the PR branch
4. Transitive vulns: add to ignore list and create a GitHub issue for tracking
5. Re-run pip-audit with transitive ignores — PR passes only if direct vulns are resolved
6. Scheduled runs also validate that previously ignored vulns are still unfixable

Removes continue-on-error: true so the action actually blocks.
2026-06-06 15:48:26 +08:00
3 changed files with 202 additions and 229 deletions

View File

@@ -9,7 +9,9 @@ on:
- cron: '0 9 * * 1'
permissions:
contents: read
contents: write
pull-requests: write
issues: write
jobs:
pip-audit:
@@ -18,7 +20,7 @@ jobs:
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
persist-credentials: ${{ github.event_name == 'pull_request' }}
- name: Restore global uv cache
id: cache-restore
@@ -46,46 +48,197 @@ jobs:
run: uv pip install pip-audit
- name: Run pip-audit
id: audit
run: |
uv run pip-audit --desc --aliases --skip-editable --format json --output pip-audit-report.json \
--ignore-vuln PYSEC-2024-277 \
--ignore-vuln PYSEC-2026-89 \
--ignore-vuln PYSEC-2026-97 \
--ignore-vuln PYSEC-2025-148 \
--ignore-vuln PYSEC-2025-183 \
--ignore-vuln PYSEC-2025-189 \
--ignore-vuln PYSEC-2025-190 \
--ignore-vuln PYSEC-2025-191 \
--ignore-vuln PYSEC-2025-192 \
--ignore-vuln PYSEC-2025-193 \
--ignore-vuln PYSEC-2025-194 \
--ignore-vuln PYSEC-2025-195 \
--ignore-vuln PYSEC-2025-196 \
--ignore-vuln PYSEC-2025-197 \
--ignore-vuln PYSEC-2025-210 \
--ignore-vuln PYSEC-2026-139 \
--ignore-vuln PYSEC-2025-211 \
--ignore-vuln PYSEC-2025-212 \
--ignore-vuln PYSEC-2025-213 \
--ignore-vuln PYSEC-2025-214 \
--ignore-vuln PYSEC-2025-215 \
--ignore-vuln PYSEC-2025-216 \
--ignore-vuln PYSEC-2025-217 \
--ignore-vuln PYSEC-2025-218 \
--ignore-vuln GHSA-f4j7-r4q5-qw2c
# Ignored CVEs:
# PYSEC-2024-277 - joblib 1.5.3: disputed; NumpyArrayWrapper only used with trusted caches
# PYSEC-2026-89 - markdown 3.10.2: DoS via malformed HTML; fix 3.8.1 — already past, advisory range is stale
# PYSEC-2026-97 - nltk 3.9.4: arbitrary file read in filestring(); no fix available
# PYSEC-2025-148 - onnx 1.21.0: path traversal in save_external_data; no fix available
# PYSEC-2025-183 - pyjwt 2.12.1: disputed weak-encryption claim; key length is application-chosen
# PYSEC-2025-189..197 - torch 2.11.0: memory-corruption/DoS in functions only reachable via untrusted models; no fix available
# PYSEC-2025-210, PYSEC-2026-139 - torch 2.11.0: profiler/deserialization issues; no fix available
# PYSEC-2025-211..218 - transformers 5.5.4: deserialization/code injection via malicious model checkpoints; no fix available
# GHSA-f4j7-r4q5-qw2c - chromadb 1.1.1 (CVE-2026-45829): pre-auth RCE via /api/v2/tenants/{tenant}/databases/{db}/collections when trust_remote_code=true.
# Advisory: vulnerable >=1.0.0,<=1.5.9, firstPatchedVersion=none. We only use chromadb.PersistentClient (lib/crewai/src/crewai/rag/chromadb/factory.py)
# and chromadb.utils.embedding_functions; the chromadb HTTP server is never started, so the vulnerable route is not exposed.
continue-on-error: true
uv run pip-audit --desc --aliases --skip-editable --format json --output pip-audit-report.json || true
# Intentionally ignore exit code — we parse the JSON ourselves below.
- name: Classify vulnerabilities
id: classify
run: |
set -euo pipefail
python3 << 'PYEOF'
import json, sys, glob, re
from pathlib import Path
# Collect direct deps from all pyproject.toml files in the monorepo
try:
import tomllib
except ImportError:
import tomli as tomllib
direct_deps = set()
for toml_path in glob.glob("**/pyproject.toml", recursive=True):
if "templates/" in toml_path or "node_modules/" in toml_path:
continue
try:
with open(toml_path, "rb") as f:
data = tomllib.load(f)
except Exception:
continue
project = data.get("project", {})
for dep_str in project.get("dependencies", []):
name = re.split(r"[><=!~\[]", dep_str)[0].strip().lower()
direct_deps.add(name)
for group_deps in project.get("optional-dependencies", {}).values():
for dep_str in group_deps:
name = re.split(r"[><=!~\[]", dep_str)[0].strip().lower()
direct_deps.add(name)
for group_deps in data.get("dependency-groups", {}).values():
if isinstance(group_deps, list):
for dep_str in group_deps:
if isinstance(dep_str, str):
name = re.split(r"[><=!~\[]", dep_str)[0].strip().lower()
direct_deps.add(name)
# Load pip-audit report
try:
with open("pip-audit-report.json") as f:
report = json.load(f)
except FileNotFoundError:
print("::error::pip-audit report not found")
sys.exit(1)
deps = report.get("dependencies", [])
vulns = [d for d in deps if d.get("vulns")]
if not vulns:
print("No vulnerabilities found")
Path("direct_vulns.txt").write_text("")
Path("transitive_vulns.txt").write_text("")
Path("transitive_ids.txt").write_text("")
sys.exit(0)
direct_vulns = []
transitive_vulns = []
transitive_ids = []
for dep in vulns:
name = dep["name"]
version = dep["version"]
is_direct = name.lower() in direct_deps
for v in dep["vulns"]:
entry = f"{name}=={version} ({v['id']})"
if is_direct:
direct_vulns.append(entry)
else:
transitive_vulns.append(entry)
transitive_ids.append(v['id'])
Path("direct_vulns.txt").write_text("\n".join(direct_vulns) if direct_vulns else "")
Path("transitive_vulns.txt").write_text("\n".join(transitive_vulns) if transitive_vulns else "")
Path("transitive_ids.txt").write_text("\n".join(transitive_ids) if transitive_ids else "")
print(f"Direct: {len(direct_vulns)}, Transitive: {len(transitive_vulns)}")
for v in direct_vulns:
print(f" DIRECT: {v}")
for v in transitive_vulns:
print(f" TRANSITIVE: {v}")
PYEOF
# Set outputs
if [ -s direct_vulns.txt ]; then
echo "has_direct=true" >> "$GITHUB_OUTPUT"
else
echo "has_direct=false" >> "$GITHUB_OUTPUT"
fi
if [ -s transitive_vulns.txt ]; then
echo "has_transitive=true" >> "$GITHUB_OUTPUT"
else
echo "has_transitive=false" >> "$GITHUB_OUTPUT"
fi
- name: Attempt fix for direct vulnerabilities
if: github.event_name == 'pull_request' && steps.classify.outputs.has_direct == 'true'
id: fix
run: |
set -euo pipefail
echo "Attempting to fix direct vulnerabilities..."
cat direct_vulns.txt
# Try pip-audit --fix to bump direct deps
uv run pip-audit --fix --skip-editable 2>&1 || true
# Check if uv.lock changed
if git diff --quiet uv.lock; then
echo "fixed=false" >> "$GITHUB_OUTPUT"
echo "::warning::Could not auto-fix direct vulnerabilities. Manual intervention required."
else
echo "fixed=true" >> "$GITHUB_OUTPUT"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add uv.lock
git commit -m "fix: bump dependencies to resolve security vulnerabilities
Auto-fixed by vulnerability-scan workflow.
Resolved: $(cat direct_vulns.txt | tr '\n' ', ')"
git push
fi
- name: Add transitive vulns to ignore list and create issues
if: steps.classify.outputs.has_transitive == 'true'
id: ignore
run: |
set -euo pipefail
# Build --ignore-vuln flags from transitive vuln IDs
IGNORE_FLAGS=""
while IFS= read -r vuln_id; do
if [ -n "$vuln_id" ]; then
IGNORE_FLAGS="$IGNORE_FLAGS --ignore-vuln $vuln_id"
fi
done < transitive_ids.txt
echo "ignore_flags=$IGNORE_FLAGS" >> "$GITHUB_OUTPUT"
# Create GitHub issues for transitive vulns
while IFS= read -r line; do
if [ -z "$line" ]; then continue; fi
VULN_ID=$(echo "$line" | grep -oE '[A-Z]+-[0-9]+-[0-9]+|GHSA-[a-z0-9-]+' || true)
PKG=$(echo "$line" | cut -d'=' -f1)
# Check if issue already exists
EXISTING=$(gh issue list --label "security,transitive-vuln" --state open --json title \
--jq ".[] | select(.title | contains(\"$VULN_ID\"))" || true)
if [ -z "$EXISTING" ]; then
gh issue create \
--title "🔒 Transitive vulnerability: $VULN_ID in $PKG" \
--label "security,transitive-vuln" \
--body "## Transitive Dependency Vulnerability
**Package:** \`$line\`
**Vulnerability:** $VULN_ID
**Status:** No fix available upstream
This vulnerability is in a transitive dependency and cannot be fixed directly. It has been added to the pip-audit ignore list until an upstream fix is available.
### Action Required
- [ ] Monitor upstream for a fix
- [ ] Remove from ignore list once fixed
- [ ] Close this issue when resolved
_Auto-created by vulnerability-scan workflow._"
fi
done < <(cat transitive_vulns.txt)
- name: Re-run pip-audit with transitive ignores
if: steps.classify.outputs.has_transitive == 'true'
id: audit-final
run: |
IGNORE_FLAGS="${{ steps.ignore.outputs.ignore_flags }}"
eval uv run pip-audit --desc --aliases --skip-editable --format json \
--output pip-audit-report.json \
$IGNORE_FLAGS
- name: Fail if direct vulnerabilities remain unfixed
if: steps.classify.outputs.has_direct == 'true' && steps.fix.outputs.fixed != 'true'
run: |
echo "::error::Direct vulnerabilities found that could not be auto-fixed:"
cat direct_vulns.txt
echo ""
echo "Fix these manually or run: pip-audit --fix"
exit 1
- name: Display results
if: always()
@@ -95,23 +248,8 @@ jobs:
echo '```json' >> $GITHUB_STEP_SUMMARY
cat pip-audit-report.json | python3 -m json.tool >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
# Fail if vulnerabilities found
python3 -c "
import json, sys
with open('pip-audit-report.json') as f:
data = json.load(f)
vulns = [d for d in data.get('dependencies', []) if d.get('vulns')]
if vulns:
print(f'::error::Found vulnerabilities in {len(vulns)} package(s)')
for v in vulns:
for vuln in v['vulns']:
print(f' - {v[\"name\"]}=={v[\"version\"]}: {vuln[\"id\"]}')
sys.exit(1)
print('No known vulnerabilities found')
"
else
echo "::error::pip-audit failed to produce a report. Check the pip-audit step logs."
exit 1
echo "::error::pip-audit failed to produce a report."
fi
- name: Upload pip-audit report
@@ -130,4 +268,3 @@ jobs:
~/.local/share/uv
.venv
key: uv-main-py3.11-${{ hashFiles('uv.lock') }}

View File

@@ -179,8 +179,7 @@ class SyncHumanInputProvider(HumanInputProvider):
Returns:
The final answer after feedback processing.
"""
output_str = self._get_output_string(formatted_answer)
feedback = self._prompt_input(context.crew, output_str)
feedback = self._prompt_input(context.crew)
if context._is_training_mode():
return self._handle_training_feedback(formatted_answer, feedback, context)
@@ -201,8 +200,7 @@ class SyncHumanInputProvider(HumanInputProvider):
Returns:
The final answer after feedback processing.
"""
output_str = self._get_output_string(formatted_answer)
feedback = await self._prompt_input_async(context.crew, output_str)
feedback = await self._prompt_input_async(context.crew)
if context._is_training_mode():
return await self._handle_training_feedback_async(
@@ -261,9 +259,7 @@ class SyncHumanInputProvider(HumanInputProvider):
else:
context.messages.append(context._format_feedback_message(feedback))
answer = context._invoke_loop()
feedback = self._prompt_input(
context.crew, self._get_output_string(answer)
)
feedback = self._prompt_input(context.crew)
return answer
@@ -315,19 +311,16 @@ class SyncHumanInputProvider(HumanInputProvider):
else:
context.messages.append(context._format_feedback_message(feedback))
answer = await context._ainvoke_loop()
feedback = await self._prompt_input_async(
context.crew, self._get_output_string(answer)
)
feedback = await self._prompt_input_async(context.crew)
return answer
@staticmethod
def _prompt_input(crew: Crew | None, agent_output: str | None = None) -> str:
def _prompt_input(crew: Crew | None) -> str:
"""Show rich panel and prompt for input.
Args:
crew: The crew instance for context.
agent_output: The agent's final output to display for review.
Returns:
User input string from terminal.
@@ -341,17 +334,6 @@ class SyncHumanInputProvider(HumanInputProvider):
formatter.pause_live_updates()
try:
if agent_output is not None:
result_content = Text()
result_content.append(agent_output, style="bright_green")
result_panel = Panel(
result_content,
title="Agent Final Answer",
border_style="green",
padding=(1, 2),
)
formatter.console.print(result_panel)
if crew and getattr(crew, "_train", False):
prompt_text = (
"TRAINING MODE: Provide feedback to improve the agent's performance.\n\n"
@@ -387,14 +369,11 @@ class SyncHumanInputProvider(HumanInputProvider):
formatter.resume_live_updates()
@staticmethod
async def _prompt_input_async(
crew: Crew | None, agent_output: str | None = None
) -> str:
async def _prompt_input_async(crew: Crew | None) -> str:
"""Show rich panel and prompt for input without blocking the event loop.
Args:
crew: The crew instance for context.
agent_output: The agent's final output to display for review.
Returns:
User input string from terminal.
@@ -408,17 +387,6 @@ class SyncHumanInputProvider(HumanInputProvider):
formatter.pause_live_updates()
try:
if agent_output is not None:
result_content = Text()
result_content.append(agent_output, style="bright_green")
result_panel = Panel(
result_content,
title="Agent Final Answer",
border_style="green",
padding=(1, 2),
)
formatter.console.print(result_panel)
if crew and getattr(crew, "_train", False):
prompt_text = (
"TRAINING MODE: Provide feedback to improve the agent's performance.\n\n"

View File

@@ -138,136 +138,4 @@ class TestFlowHumanInputIntegration:
for call in call_args
if call[0]
)
assert training_panel_found
@patch("builtins.input", return_value="")
def test_prompt_input_displays_agent_output(self, mock_input):
"""Test that _prompt_input displays the agent output in a panel when provided."""
provider = SyncHumanInputProvider()
crew = MagicMock()
crew._train = False
formatter = event_listener.formatter
with (
patch.object(formatter, "pause_live_updates"),
patch.object(formatter, "resume_live_updates"),
patch.object(formatter.console, "print") as mock_console_print,
):
provider._prompt_input(crew, agent_output="The sky is blue.")
mock_console_print.assert_called()
call_args = mock_console_print.call_args_list
result_panel_found = any(
hasattr(call[0][0], "title")
and "Agent Final Answer" in str(call[0][0].title)
for call in call_args
if call[0]
)
assert result_panel_found, (
"Agent output panel should be displayed when agent_output is provided"
)
@patch("builtins.input", return_value="")
def test_prompt_input_no_output_panel_when_none(self, mock_input):
"""Test that _prompt_input does not display the output panel when agent_output is None."""
provider = SyncHumanInputProvider()
crew = MagicMock()
crew._train = False
formatter = event_listener.formatter
with (
patch.object(formatter, "pause_live_updates"),
patch.object(formatter, "resume_live_updates"),
patch.object(formatter.console, "print") as mock_console_print,
):
provider._prompt_input(crew, agent_output=None)
call_args = mock_console_print.call_args_list
result_panel_found = any(
hasattr(call[0][0], "title")
and "Agent Final Answer" in str(call[0][0].title)
for call in call_args
if call[0]
)
assert not result_panel_found, (
"Agent output panel should NOT be displayed when agent_output is None"
)
@patch("builtins.input", return_value="looks good")
def test_handle_feedback_passes_output_to_prompt(self, mock_input):
"""Test that handle_feedback passes the agent output to _prompt_input.
This is the core fix for issue #6072: the feedback prompt should always
display the result being reviewed, regardless of verbose setting.
"""
from crewai.agents.parser import AgentFinish
provider = SyncHumanInputProvider()
formatted_answer = AgentFinish(
thought="I know the answer",
output="The sky is blue.",
text="Final Answer: The sky is blue.",
)
context = MagicMock()
context.crew = MagicMock()
context.crew._train = False
context._is_training_mode.return_value = False
context.ask_for_human_input = False
formatter = event_listener.formatter
with (
patch.object(formatter, "pause_live_updates"),
patch.object(formatter, "resume_live_updates"),
patch.object(formatter.console, "print") as mock_console_print,
):
provider.handle_feedback(formatted_answer, context)
call_args = mock_console_print.call_args_list
result_panel_found = any(
hasattr(call[0][0], "title")
and "Agent Final Answer" in str(call[0][0].title)
for call in call_args
if call[0]
)
assert result_panel_found, (
"handle_feedback must display the agent result panel "
"so the user can see what they are reviewing (issue #6072)"
)
@patch("builtins.input", return_value="")
def test_prompt_input_displays_output_before_feedback_panel(self, mock_input):
"""Test that the agent output panel appears before the feedback panel."""
provider = SyncHumanInputProvider()
crew = MagicMock()
crew._train = False
formatter = event_listener.formatter
with (
patch.object(formatter, "pause_live_updates"),
patch.object(formatter, "resume_live_updates"),
patch.object(formatter.console, "print") as mock_console_print,
):
provider._prompt_input(crew, agent_output="Test output")
call_args = mock_console_print.call_args_list
result_panel_idx = None
feedback_panel_idx = None
for i, call in enumerate(call_args):
if call[0] and hasattr(call[0][0], "title"):
title = str(call[0][0].title)
if "Agent Final Answer" in title:
result_panel_idx = i
elif "Human Feedback" in title:
feedback_panel_idx = i
assert result_panel_idx is not None, "Agent output panel should be present"
assert feedback_panel_idx is not None, "Feedback panel should be present"
assert result_panel_idx < feedback_panel_idx, (
"Agent output panel should appear before the feedback panel"
)
assert training_panel_found