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
137 changes: 40 additions & 97 deletions .github/workflows/validate-pr.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
name: Validate PR
on:
pull_request:
paths: ['skillshare-hub.json']
paths:
- 'skillshare-hub.json'
- 'scripts/**'
- '.github/workflows/validate-pr.yml'

permissions:
contents: read
pull-requests: write

jobs:
validate:
Expand All @@ -10,32 +17,7 @@ jobs:
- uses: actions/checkout@v4

- name: Validate hub.json
run: |
# Check valid JSON
jq empty skillshare-hub.json || exit 1

# Check required fields
missing=$(jq -r '.skills[] | select(.name == "" or .name == null or .description == "" or .description == null or .source == "" or .source == null) | .name // "unnamed"' skillshare-hub.json)
if [ -n "$missing" ]; then
echo "ERROR: Skills missing required fields: $missing"
exit 1
fi

# Check no duplicate names
dupes=$(jq -r '[.skills[].name] | group_by(.) | map(select(length > 1)) | flatten | .[]' skillshare-hub.json)
if [ -n "$dupes" ]; then
echo "ERROR: Duplicate skill names: $dupes"
exit 1
fi

# Check name format (lowercase, hyphens only)
bad_names=$(jq -r '.skills[].name | select(test("^[a-z0-9][a-z0-9-]*$") | not)' skillshare-hub.json)
if [ -n "$bad_names" ]; then
echo "ERROR: Invalid skill names (must be lowercase, hyphens): $bad_names"
exit 1
fi

echo "Validation passed: $(jq '.skills | length' skillshare-hub.json) skills"
run: ./scripts/validate.sh

audit:
needs: validate
Expand All @@ -45,80 +27,41 @@ jobs:
with:
fetch-depth: 0

- name: Find new/changed skill sources
id: diff
- name: Install skillshare
run: |
curl -fsSL https://raw.githubusercontent.com/runkids/skillshare/main/install.sh | sh
skillshare version
skillshare init -g --no-copy --no-targets --no-git --no-skill

- name: Audit new skills
id: audit
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
CI: "true"
AUDIT_REPORT: /tmp/audit-report.md
run: |
# Get base version sources (empty if file doesn't exist in base)
base_sources=$(git show "${BASE_SHA}:skillshare-hub.json" 2>/dev/null \
| jq -r '.skills[] | "\(.name)|\(.source)"' | sort) || base_sources=""

pr_sources=$(jq -r '.skills[] | "\(.name)|\(.source)"' skillshare-hub.json | sort)

# Find new or changed entries (name|source pairs not in base)
new_sources=$(comm -13 <(echo "$base_sources") <(echo "$pr_sources") | cut -d'|' -f2)

if [ -z "$new_sources" ]; then
echo "No new or changed skill sources to audit"
echo "has_new=false" >> "$GITHUB_OUTPUT"
else
echo "New/changed sources to audit:"
echo "$new_sources"
echo "$new_sources" > /tmp/new-sources.txt
echo "has_new=true" >> "$GITHUB_OUTPUT"
fi
./scripts/audit.sh || echo "audit_failed=true" >> "$GITHUB_OUTPUT"
[ -f /tmp/audit-report.md ] && echo "has_report=true" >> "$GITHUB_OUTPUT"

- name: Download skillshare
if: steps.diff.outputs.has_new == 'true'
- name: Comment PR with audit results
if: always() && steps.audit.outputs.has_report == 'true'
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
gh release download --repo runkids/skillshare \
--pattern 'skillshare_*_linux_amd64.tar.gz' \
--output /tmp/skillshare.tar.gz
tar xzf /tmp/skillshare.tar.gz -C /usr/local/bin skillshare
skillshare version

- name: Audit new skills
if: steps.diff.outputs.has_new == 'true'
run: |
failed=0
while IFS= read -r source; do
[ -z "$source" ] && continue

# Determine clone URL
if [[ "$source" == http* ]]; then
clone_url="$source"
else
clone_url="https://github.com/${source}.git"
fi

safe_name=$(echo "$source" | tr '/' '-')
clone_dir="/tmp/audit-${safe_name}"

echo "::group::Auditing ${source}"

if ! git clone --depth 1 "$clone_url" "$clone_dir" 2>&1; then
echo "::error::Failed to clone ${source}"
failed=1
echo "::endgroup::"
continue
fi

if ! skillshare audit "$clone_dir" --threshold high; then
echo "::error::Security audit failed for ${source}"
failed=1
fi

rm -rf "$clone_dir"
echo "::endgroup::"
done < /tmp/new-sources.txt

if [ "$failed" -ne 0 ]; then
echo ""
echo "::error::One or more skills failed the security audit. Please fix the flagged issues and push again."
exit 1
fi

echo "All new skills passed the security audit"
# Delete previous audit comment if exists
gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \
--jq '.[] | select(.body | startswith("## Skill Audit Results")) | .id' \
| while read -r id; do
gh api -X DELETE "repos/${{ github.repository }}/issues/comments/${id}"
done

gh pr comment "${PR_NUMBER}" --body-file /tmp/audit-report.md

- name: Show audit summary
if: always() && steps.audit.outputs.has_report == 'true'
run: cat /tmp/audit-report.md >> "$GITHUB_STEP_SUMMARY"

- name: Fail if audit failed
if: steps.audit.outputs.audit_failed == 'true'
run: exit 1
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.claude/
12 changes: 12 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.PHONY: format validate audit ci

format: ## Format skillshare-hub.json (sort by name, 2-space indent)
@./scripts/format.sh

validate: ## Validate skillshare-hub.json format and rules
@./scripts/validate.sh

audit: ## Audit new/changed skills against main
@./scripts/audit.sh main

ci: validate audit ## Run full CI locally
129 changes: 129 additions & 0 deletions scripts/audit.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
set -euo pipefail

HUB_FILE="skillshare-hub.json"
BASE_REF="${1:-${BASE_SHA:-main}}"
REPORT_FILE="${AUDIT_REPORT:-}"

# Detect environment
is_ci() { [ "${CI:-}" = "true" ]; }
log_group() { is_ci && echo "::group::$1" || echo "=== $1 ==="; }
log_endgroup() { is_ci && echo "::endgroup::" || true; }
log_error() { is_ci && echo "::error::$1" || echo "ERROR: $1"; }

# --- Find new/changed sources ---
base_sources=$(git show "${BASE_REF}:${HUB_FILE}" 2>/dev/null \
| jq -r '.skills[] | "\(.name)|\(.source)"' | sort) || base_sources=""

pr_sources=$(jq -r '.skills[] | "\(.name)|\(.source)"' "$HUB_FILE" | sort)

new_entries=$(comm -13 <(echo "$base_sources") <(echo "$pr_sources"))
new_sources=$(echo "$new_entries" | cut -d'|' -f2 | sort -u)

if [ -z "$new_entries" ]; then
echo "No new or changed skill sources to audit"
[ -n "$REPORT_FILE" ] && echo "No new or changed skill sources to audit." > "$REPORT_FILE"
exit 0
fi

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The script prints the contents of the new_sources variable directly to the console. new_sources is derived from the source field in skillshare-hub.json, which is untrusted input from a pull request. If a malicious source contains a newline followed by a GitHub Actions workflow command (e.g., ::error::), the command will be executed by the GitHub Actions runner. This can be used to spoof CI results or manipulate the workflow.


echo "New/changed sources to audit:"
echo "$new_sources"
echo ""

# --- Check skillshare is available ---
if ! command -v skillshare &>/dev/null; then
echo "ERROR: skillshare CLI not found. Install from https://github.com/runkids/skillshare"
exit 1
fi

# --- Audit each source ---
failed=0
results=()
details=()

while IFS= read -r source; do
[ -z "$source" ] && continue

if [[ "$source" == http* ]]; then
clone_url="$source"
else

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using a predictable temporary directory name can lead to race conditions if multiple instances of this script run concurrently. It's safer to use mktemp -d to create a unique directory. For even more robust cleanup on script interruption, consider using a trap at the script level to remove the temporary directory on exit.

Suggested change
else
clone_dir=$(mktemp -d "/tmp/audit-${safe_name}.XXXXXX")

clone_url="https://github.com/${source}.git"
fi

safe_name=$(echo "$source" | tr '/' '-')
clone_dir="/tmp/audit-${safe_name}"

log_group "Auditing ${source}"

if ! clone_output=$(git clone --depth 1 "$clone_url" "$clone_dir" 2>&1); then
log_error "Failed to clone ${source}"
results+=("| \`${source}\` | :x: Clone failed | - |")
details+=("DETAIL_SEP### \`${source}\`"$'\n'"Clone failed:"$'\n'"\`\`\`"$'\n'"${clone_output}"$'\n'"\`\`\`")
failed=1
log_endgroup
continue
fi

audit_output=$(skillshare audit "$clone_dir" --threshold high 2>&1 | sed 's/\x1b\[[0-9;]*m//g') || true

echo "$audit_output"

# Extract risk score and label
risk=$(echo "$audit_output" | sed -n 's/.*Risk: \([A-Z]* ([0-9]*\/[0-9]*)\).*/\1/p' | tail -1)
[ -z "$risk" ] && risk="N/A"
risk_label=$(echo "$risk" | awk '{print $1}')

if echo "$audit_output" | grep -q "config not found"; then
results+=("| \`${source}\` | :x: No config | - |")
details+=("DETAIL_SEP### \`${source}\`"$'\n'"No skillshare config found. Run \`skillshare init\` in the source repo.")
log_error "No skillshare config found for ${source}. Run 'skillshare init' in the source repo."
failed=1
elif [[ "$risk_label" == "HIGH" || "$risk_label" == "CRITICAL" ]]; then
results+=("| \`${source}\` | :x: Risk ${risk_label} | ${risk} |")
details+=("DETAIL_SEP### \`${source}\`"$'\n'"\`\`\`"$'\n'"${audit_output}"$'\n'"\`\`\`")
log_error "Risk ${risk_label} for ${source}"
failed=1
else
results+=("| \`${source}\` | :white_check_mark: Passed | ${risk} |")
details+=("DETAIL_SEP### \`${source}\`"$'\n'"\`\`\`"$'\n'"${audit_output}"$'\n'"\`\`\`")
fi

rm -rf "$clone_dir"
log_endgroup
done <<< "$new_sources"

# --- Generate report ---
if [ -n "$REPORT_FILE" ]; then
{
echo "## Skill Audit Results"
echo ""
echo "| Source | Status | Risk |"
echo "|--------|--------|------|"
for row in "${results[@]}"; do
echo "$row"
done
echo ""
if [ "$failed" -ne 0 ]; then
echo "> **Failed**: One or more skills did not pass the security audit."
else
echo "> **All skills passed** the security audit."
fi
echo ""
echo "<details>"
echo "<summary>Audit Details</summary>"
echo ""
for detail in "${details[@]}"; do
echo "${detail}" | sed 's/^DETAIL_SEP//'
echo ""
done
echo "</details>"
} > "$REPORT_FILE"
fi

if [ "$failed" -ne 0 ]; then
echo ""
log_error "One or more skills failed the security audit. Please fix the flagged issues and push again."
exit 1
fi

echo "All new skills passed the security audit"
14 changes: 14 additions & 0 deletions scripts/format.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail

HUB_FILE="${1:-skillshare-hub.json}"

if [ ! -f "$HUB_FILE" ]; then
echo "ERROR: $HUB_FILE not found"
exit 1
fi

jq '.skills |= sort_by(.name)' "$HUB_FILE" > "${HUB_FILE}.tmp" \
&& mv "${HUB_FILE}.tmp" "$HUB_FILE"

echo "Formatted: $HUB_FILE ($(jq '.skills | length' "$HUB_FILE") skills, sorted by name)"
52 changes: 52 additions & 0 deletions scripts/validate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -euo pipefail

HUB_FILE="${1:-skillshare-hub.json}"

if [ ! -f "$HUB_FILE" ]; then
echo "ERROR: $HUB_FILE not found"
exit 1
fi

# Check valid JSON
if ! jq empty "$HUB_FILE" 2>/dev/null; then
echo "ERROR: Invalid JSON in $HUB_FILE"
exit 1
fi
echo "OK: valid JSON"

# Check required fields
missing=$(jq -r '.skills[] | select(.name == "" or .name == null or .description == "" or .description == null or .source == "" or .source == null) | .name // "unnamed"' "$HUB_FILE")
if [ -n "$missing" ]; then
echo "ERROR: Skills missing required fields: $missing"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The missing variable, which contains skill names from skillshare-hub.json, is printed directly to the console. This creates a GitHub Actions Command Injection vulnerability if a skill name contains a newline followed by a workflow command. It is crucial to validate the format of skill names before using them in any output. Additionally, for readability, if multiple skills are missing, they should be listed on separate lines.

exit 1
fi
echo "OK: required fields present"

# Check no duplicate names
dupes=$(jq -r '[.skills[].name] | group_by(.) | map(select(length > 1)) | flatten | .[]' "$HUB_FILE")
if [ -n "$dupes" ]; then
echo "ERROR: Duplicate skill names: $dupes"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The dupes variable, containing skill names from skillshare-hub.json, is printed directly to the console. This poses a GitHub Actions Command Injection vulnerability if a duplicate skill name includes a newline followed by a workflow command. It is critical to validate skill name formats before output. Additionally, for better readability, duplicate names should be listed on separate lines.

exit 1
fi
echo "OK: no duplicate names"

# Check name format (lowercase, hyphens only)
bad_names=$(jq -r '.skills[].name | select(test("^[a-z0-9][a-z0-9-]*$") | not)' "$HUB_FILE")
if [ -n "$bad_names" ]; then
echo "ERROR: Invalid skill names (must be lowercase, hyphens): $bad_names"
exit 1
fi
Comment on lines +36 to +39

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If there are multiple invalid skill names, they will be displayed on a single line, making the list difficult to parse. Displaying each invalid name on a new line would be more readable.

Suggested change
if [ -n "$bad_names" ]; then
echo "ERROR: Invalid skill names (must be lowercase, hyphens): $bad_names"
exit 1
fi
if [ -n "$bad_names" ]; then
echo "ERROR: Invalid skill names (must be lowercase, hyphens):"
echo "$bad_names"
exit 1
fi

echo "OK: name format valid"

# Check formatting (sorted by name, jq default 2-space indent)
expected=$(jq '.skills |= sort_by(.name)' "$HUB_FILE")
actual=$(cat "$HUB_FILE")
if [ "$expected" != "$actual" ]; then
echo "ERROR: $HUB_FILE is not formatted. Run: make format"
exit 1
fi
echo "OK: formatting correct"

echo ""
echo "Validation passed: $(jq '.skills | length' "$HUB_FILE") skills"
14 changes: 13 additions & 1 deletion skillshare-hub.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,19 @@
"description": "Verify and fix ASCII box-drawing diagram alignment in markdown files",
"source": "runkids/my-skills",
"skill": "ascii-box-check",
"tags": ["docs", "workflow"]
"tags": [
"docs",
"workflow"
]
},
{
"name": "skill-creator",
"description": "Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations.",
"source": "anthropics/skills",
"skill": "skill-creator",
"tags": [
"skill"
]
}
]
}