Skip to content

Commit ca336b5

Browse files
Merge pull request #303 from voidborne-d/feat/skill-security-audit-ci
ci: integrate skill-security-auditor as automated PR check
2 parents 8428901 + ad727f1 commit ca336b5

1 file changed

Lines changed: 237 additions & 0 deletions

File tree

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
---
2+
name: Skill Security Audit
3+
4+
'on':
5+
pull_request:
6+
types: [opened, synchronize, reopened]
7+
paths:
8+
- 'engineering/**'
9+
- 'engineering-team/**'
10+
- 'business-growth/**'
11+
- 'c-level-advisor/**'
12+
- 'documentation/**'
13+
- 'finance/**'
14+
- 'marketing-skill/**'
15+
- 'product-team/**'
16+
- 'project-management/**'
17+
- 'ra-qm-team/**'
18+
- 'agents/**'
19+
- 'templates/**'
20+
21+
concurrency:
22+
group: security-audit-${{ github.event.pull_request.number }}
23+
cancel-in-progress: true
24+
25+
jobs:
26+
detect-changes:
27+
name: Detect changed skills
28+
runs-on: ubuntu-latest
29+
permissions:
30+
contents: read
31+
outputs:
32+
skills: ${{ steps.find.outputs.skills }}
33+
has_skills: ${{ steps.find.outputs.has_skills }}
34+
steps:
35+
- name: Checkout
36+
uses: actions/checkout@v4
37+
with:
38+
fetch-depth: 0
39+
40+
- name: Find changed skill directories
41+
id: find
42+
run: |
43+
# Get list of changed files in PR
44+
CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD 2>/dev/null || echo "")
45+
46+
if [ -z "$CHANGED" ]; then
47+
echo "skills=[]" >> "$GITHUB_OUTPUT"
48+
echo "has_skills=false" >> "$GITHUB_OUTPUT"
49+
exit 0
50+
fi
51+
52+
# Extract unique skill root directories (top-level dirs containing SKILL.md)
53+
SKILLS=()
54+
SEEN=()
55+
while IFS= read -r file; do
56+
# Get the top-level directory
57+
dir=$(echo "$file" | cut -d'/' -f1-2)
58+
# Skip non-skill paths
59+
case "$dir" in
60+
.github/*|.claude/*|.codex/*|.gemini/*|docs/*|scripts/*|commands/*|standards/*|eval-workspace/*) continue ;;
61+
esac
62+
# Check if this directory has a SKILL.md (is a skill)
63+
skill_root=$(echo "$file" | cut -d'/' -f1)
64+
# Walk up to find the skill root that contains SKILL.md
65+
for candidate in "$skill_root" "$dir"; do
66+
if [ -f "$candidate/SKILL.md" ] && [[ ! " ${SEEN[*]} " =~ " $candidate " ]]; then
67+
SKILLS+=("$candidate")
68+
SEEN+=("$candidate")
69+
break
70+
fi
71+
done
72+
done <<< "$CHANGED"
73+
74+
if [ ${#SKILLS[@]} -eq 0 ]; then
75+
echo "skills=[]" >> "$GITHUB_OUTPUT"
76+
echo "has_skills=false" >> "$GITHUB_OUTPUT"
77+
else
78+
# Build JSON array
79+
JSON="["
80+
for i in "${!SKILLS[@]}"; do
81+
[ $i -gt 0 ] && JSON+=","
82+
JSON+="\"${SKILLS[$i]}\""
83+
done
84+
JSON+="]"
85+
echo "skills=$JSON" >> "$GITHUB_OUTPUT"
86+
echo "has_skills=true" >> "$GITHUB_OUTPUT"
87+
echo "Changed skills: $JSON"
88+
fi
89+
90+
audit:
91+
name: Security audit
92+
needs: detect-changes
93+
if: needs.detect-changes.outputs.has_skills == 'true'
94+
runs-on: ubuntu-latest
95+
permissions:
96+
contents: read
97+
pull-requests: write
98+
steps:
99+
- name: Checkout
100+
uses: actions/checkout@v4
101+
102+
- name: Set up Python
103+
uses: actions/setup-python@v5
104+
with:
105+
python-version: '3.11'
106+
107+
- name: Run security auditor on changed skills
108+
id: audit
109+
run: |
110+
AUDITOR="engineering/skill-security-auditor/scripts/skill_security_auditor.py"
111+
SKILLS='${{ needs.detect-changes.outputs.skills }}'
112+
REPORT_FILE=$(mktemp)
113+
OVERALL_EXIT=0
114+
115+
echo "## 🔒 Skill Security Audit Results" > "$REPORT_FILE"
116+
echo "" >> "$REPORT_FILE"
117+
118+
# Parse JSON array of skill dirs
119+
for skill_dir in $(echo "$SKILLS" | python3 -c "import sys,json; [print(s) for s in json.load(sys.stdin)]"); do
120+
echo "::group::Auditing $skill_dir"
121+
echo "Scanning: $skill_dir"
122+
123+
# Run auditor in strict mode with JSON output
124+
JSON_OUT=$(python3 "$AUDITOR" "$skill_dir" --strict --json 2>&1) && EXIT_CODE=$? || EXIT_CODE=$?
125+
126+
# Try to parse JSON output
127+
VERDICT=$(echo "$JSON_OUT" | python3 -c "
128+
import sys, json
129+
try:
130+
d = json.load(sys.stdin)
131+
v = d.get('verdict', 'UNKNOWN')
132+
c = d.get('summary', {}).get('critical', 0)
133+
h = d.get('summary', {}).get('high', 0)
134+
i = d.get('summary', {}).get('info', 0)
135+
t = d.get('summary', {}).get('total', 0)
136+
print(f'{v}|{c}|{h}|{i}|{t}')
137+
except:
138+
print('ERROR|0|0|0|0')
139+
" 2>/dev/null || echo "ERROR|0|0|0|0")
140+
141+
IFS='|' read -r V CRIT HIGH INFO TOTAL <<< "$VERDICT"
142+
143+
# Map verdict to emoji
144+
case "$V" in
145+
PASS) ICON="✅" ;;
146+
WARN) ICON="⚠️" ;;
147+
FAIL) ICON="❌"; OVERALL_EXIT=1 ;;
148+
*) ICON="❓"; OVERALL_EXIT=1 ;;
149+
esac
150+
151+
echo "### $ICON \`$skill_dir\` — $V" >> "$REPORT_FILE"
152+
echo "" >> "$REPORT_FILE"
153+
154+
if [ "$TOTAL" -gt 0 ]; then
155+
echo "| Severity | Count |" >> "$REPORT_FILE"
156+
echo "|----------|-------|" >> "$REPORT_FILE"
157+
[ "$CRIT" -gt 0 ] && echo "| 🔴 Critical | $CRIT |" >> "$REPORT_FILE"
158+
[ "$HIGH" -gt 0 ] && echo "| 🟡 High | $HIGH |" >> "$REPORT_FILE"
159+
[ "$INFO" -gt 0 ] && echo "| ⚪ Info | $INFO |" >> "$REPORT_FILE"
160+
echo "" >> "$REPORT_FILE"
161+
162+
# Include finding details for WARN/FAIL
163+
if [ "$V" != "PASS" ]; then
164+
echo "<details><summary>Findings detail</summary>" >> "$REPORT_FILE"
165+
echo "" >> "$REPORT_FILE"
166+
echo '```json' >> "$REPORT_FILE"
167+
echo "$JSON_OUT" | python3 -c "
168+
import sys, json
169+
try:
170+
d = json.load(sys.stdin)
171+
findings = d.get('findings', [])
172+
for f in findings:
173+
if f.get('severity') in ('CRITICAL', 'HIGH'):
174+
print(json.dumps(f, indent=2))
175+
except:
176+
pass
177+
" >> "$REPORT_FILE"
178+
echo '```' >> "$REPORT_FILE"
179+
echo "</details>" >> "$REPORT_FILE"
180+
echo "" >> "$REPORT_FILE"
181+
fi
182+
else
183+
echo "No findings." >> "$REPORT_FILE"
184+
echo "" >> "$REPORT_FILE"
185+
fi
186+
187+
echo "::endgroup::"
188+
done
189+
190+
# Save report for comment step
191+
echo "report_file=$REPORT_FILE" >> "$GITHUB_OUTPUT"
192+
echo "exit_code=$OVERALL_EXIT" >> "$GITHUB_OUTPUT"
193+
194+
- name: Post audit results as PR comment
195+
if: always()
196+
uses: actions/github-script@v7
197+
with:
198+
script: |
199+
const fs = require('fs');
200+
const reportFile = '${{ steps.audit.outputs.report_file }}';
201+
let body = '## 🔒 Skill Security Audit\n\nNo report generated.';
202+
try {
203+
body = fs.readFileSync(reportFile, 'utf8');
204+
} catch (e) {
205+
console.log('Could not read report file:', e.message);
206+
}
207+
208+
// Find and update existing comment or create new
209+
const { data: comments } = await github.rest.issues.listComments({
210+
owner: context.repo.owner,
211+
repo: context.repo.repo,
212+
issue_number: context.issue.number,
213+
});
214+
const marker = '## 🔒 Skill Security Audit';
215+
const existing = comments.find(c => c.body.startsWith(marker));
216+
217+
if (existing) {
218+
await github.rest.issues.updateComment({
219+
owner: context.repo.owner,
220+
repo: context.repo.repo,
221+
comment_id: existing.id,
222+
body: body,
223+
});
224+
} else {
225+
await github.rest.issues.createComment({
226+
owner: context.repo.owner,
227+
repo: context.repo.repo,
228+
issue_number: context.issue.number,
229+
body: body,
230+
});
231+
}
232+
233+
- name: Fail on critical findings
234+
if: steps.audit.outputs.exit_code == '1'
235+
run: |
236+
echo "::error::Security audit found CRITICAL findings. Merge blocked."
237+
exit 1

0 commit comments

Comments
 (0)