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
7 changes: 7 additions & 0 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# actionlint configuration for this repository.
paths:
# actionlint 1.7.12 does not know job.workflow_repository and job.workflow_sha
# yet. GitHub documents both. Remove this rule when actionlint supports them.
.github/workflows/security.yml:
ignore:
- 'property "workflow_(repository|sha)" is not defined in object type'
33 changes: 22 additions & 11 deletions .github/actions/scan-dev-configs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ If none of these directories exist in the repository, the action passes silently

| Check | What triggers a failure |
|-------|----------------------|
| Executable scripts | Any `.js`, `.mjs`, `.cjs`, `.jsx`, `.ts`, `.tsx`, `.mts`, or `.cts` file exists in a dev tool directory |
| Script files | A file in a dev tool directory is a script and `allowed-scripts` does not list it. A script is a file with a script or binary extension (`.js`, `.ts`, `.sh`, `.py`, `.ps1`, `.bat`, `.exe`, `.jar` and others), the executable bit, or a `#!` first line |
| Suspicious commands | Any config file contains execution or network patterns (see list below). Warns by default, fails with `strict: true` |

**Suspicious command patterns:**
Expand All @@ -19,18 +19,24 @@ If none of these directories exist in the repository, the action passes silently

Every pattern is matched on word boundaries, so `async` does not trigger `nc`.

Allowed scripts are scanned for suspicious commands too. The allowlist permits the file, not every command in it.

Editor task files (`.vscode/tasks.json`) and agent permission lists (`.claude/settings.json`) legitimately contain `npx` or `curl`. That is why this check warns by default. Review the warnings, and enable `strict` once the repository's config files are clean.

## Inputs

| Name | Default | Description |
|----------|---------|--------------------------------------------------------|
| `strict` | `false` | Fail on suspicious commands instead of warning. |
| Name | Default | Description |
| --- | --- | --- |
| `allowed-scripts` | `''` | Newline-separated glob patterns, relative to the repository root, of reviewed script files. `*` also matches `/`. Example: `.claude/hooks/*.sh` |
| `strict` | `'false'` | Fail on suspicious commands instead of warning. One of: `'true'`, `'false'` |

```yaml
- uses: scify/.github/.github/actions/scan-dev-configs@v0.1
- uses: scify/.github/.github/actions/scan-dev-configs@<commit-sha> # v0.1
with:
strict: true
allowed-scripts: |
.claude/hooks/*.sh
.vscode/extensions/check.js
strict: 'true'
```

## Why this exists
Expand All @@ -53,16 +59,21 @@ steps:

**Organisation-wide usage** (recommended):

The action is published from the public `scify/.github` repository. Reference it by the `v1` tag:
In a SciFY repository, call the reusable `security.yml` workflow. It runs this
action with the other security checks. See the
[workflow guide](../../workflows/README.md#security).

To use the action on its own, pin it to a full commit SHA. The organisation
requires this for every action, including actions from `scify/.github`. A tag
such as `@v0.1` fails. Get the SHA of a release with
`git ls-remote https://github.com/scify/.github refs/tags/v0.1`:

```yaml
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: scify/.github/.github/actions/scan-dev-configs@v0.1
- uses: scify/.github/.github/actions/scan-dev-configs@<commit-sha> # v0.1
```

The reusable `security.yml` workflow in the same repository already runs this action. Call that workflow instead when you want the full set of checks.

### 2. Placement

Place after `actions/checkout` and before any build or deploy steps. It runs independently of `verify-npm-hardening` and does not require Node.js or npm.
Expand All @@ -71,7 +82,7 @@ Place after `actions/checkout` and before any build or deploy steps. It runs ind

The error output names the exact file and line that triggered the failure:

- **Executable script found:** remove it from the repository. Dev tool directories should not contain executable code (`.js`, `.mjs`, `.cjs`, `.jsx`, `.ts`, `.tsx`, `.mts`, `.cts`).
- **Script file found:** remove it from the repository. If the script is legitimate, for example a Claude Code hook, review it and add its path to `allowed-scripts`. A reviewer then sees each new allowed path in the pull request.
- **Suspicious command found:** inspect the config file. Remove entries containing execution or network commands.

If the file is legitimate and expected, reconsider whether it belongs in a dev tool config directory or whether it should live elsewhere in the project.
204 changes: 125 additions & 79 deletions .github/actions/scan-dev-configs/action.yml
Original file line number Diff line number Diff line change
@@ -1,100 +1,146 @@
# Scans dev tool config directories for signs of compromise.
# Place AFTER checkout, runs independently of npm hardening.
# Place AFTER checkout. Runs independently of npm hardening.
#
# Usage:
#
# steps:
# - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
# - uses: ./.github/actions/scan-dev-configs
# - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# with:
# persist-credentials: false
# - uses: scify/.github/.github/actions/scan-dev-configs@<commit-sha> # v0.1
# with:
# allowed-scripts: |
# .claude/hooks/*.sh
# strict: 'false'
#
# Checks:
# 1. No executable script files in dev tool directories (always fails)
# 2. No suspicious commands in config files (warns, fails with strict: true)
#
# Inputs:
# strict (optional, default: false)
# Fail on suspicious commands instead of warning. Editor task files and
# agent permission lists legitimately contain `npx` or `curl`, so the
# default only warns.
# 1. No script files in dev tool directories, unless allowed-scripts lists
# them (always fails)
# 2. No suspicious commands in any file there (warns, fails with strict: true)

name: Scan dev tool configs
description: Checks .vscode, .claude, .cursor, and .idea directories for suspicious content
description: Checks .vscode, .claude, .cursor and .idea directories for scripts and suspicious commands

inputs:
strict:
description: Fail on suspicious commands instead of warning
required: false
default: 'false'
allowed-scripts:
description: "Newline-separated glob patterns, relative to the repository root, of script files that are allowed. `*` also matches `/`. Example: .claude/hooks/*.sh"
required: false
default: ''
strict:
description: "Fail on suspicious commands instead of warning. One of: 'true', 'false'"
required: false
default: 'false'

runs:
using: composite
steps:
- name: Detect dev tool directories
id: detect
shell: bash
run: |
dirs=""
for d in .vscode .claude .cursor .idea; do
[ -d "$d" ] && dirs="$dirs $d"
done
using: composite
steps:
- name: Detect dev tool directories
id: detect
shell: bash
run: |
dirs=""
for d in .vscode .claude .cursor .idea; do
[ -d "$d" ] && dirs="$dirs $d"
done

if [ -z "$dirs" ]; then
echo "No dev tool directories found in repository."
echo "found=false" >> "$GITHUB_OUTPUT"
else
echo "Scanning:$dirs"
echo "found=true" >> "$GITHUB_OUTPUT"
echo "dirs=$dirs" >> "$GITHUB_OUTPUT"
fi

- name: Check for script files
if: steps.detect.outputs.found == 'true'
shell: bash
env:
DIRS: ${{ steps.detect.outputs.dirs }}
ALLOWED: ${{ inputs.allowed-scripts }}
run: |
# A script is a file with a script or binary extension, the executable
# bit, or a shebang line. Dev tool directories should hold only config.
is_script() {
case "${1##*/}" in
*.js|*.mjs|*.cjs|*.jsx|*.ts|*.tsx|*.mts|*.cts) return 0 ;;
*.sh|*.bash|*.zsh|*.fish|*.py|*.rb|*.pl|*.php) return 0 ;;
*.ps1|*.psm1|*.bat|*.cmd|*.vbs|*.exe|*.dll|*.so|*.dylib|*.jar) return 0 ;;
esac
[ -x "$1" ] && return 0
[ "$(head -c 2 "$1" 2>/dev/null)" = '#!' ] && return 0
return 1
}

if [ -z "$dirs" ]; then
echo "No dev tool directories found in repository."
echo "found=false" >> "$GITHUB_OUTPUT"
else
echo "Scanning:$dirs"
echo "found=true" >> "$GITHUB_OUTPUT"
echo "dirs=$dirs" >> "$GITHUB_OUTPUT"
fi
is_allowed() {
local pattern
while IFS= read -r pattern; do
pattern="${pattern#"${pattern%%[![:space:]]*}"}"
pattern="${pattern%"${pattern##*[![:space:]]}"}"
[ -z "$pattern" ] && continue
# Unquoted on purpose: the pattern is a glob.
# shellcheck disable=SC2053
[[ $1 == $pattern ]] && return 0
done <<< "$ALLOWED"
return 1
}

- name: Check for executable script files
if: steps.detect.outputs.found == 'true'
shell: bash
env:
DIRS: ${{ steps.detect.outputs.dirs }}
run: |
# Dev tool directories should not contain executable scripts.
js_files=$(find $DIRS -type f \( -name "*.js" -o -name "*.mjs" -o -name "*.cjs" -o -name "*.jsx" -o -name "*.ts" -o -name "*.tsx" -o -name "*.mts" -o -name "*.cts" \) 2>/dev/null)
blocked=()
# Word splitting is intended: one argument per directory.
# shellcheck disable=SC2086
while IFS= read -r -d '' file; do
is_script "$file" || continue
if is_allowed "$file"; then
echo "::notice::Allowed script: $file"
else
blocked+=("$file")
fi
done < <(find $DIRS -type f -print0 2>/dev/null)

if [ -n "$js_files" ]; then
echo "::error::Executable script files found in dev tool directories:"
echo "$js_files" | while IFS= read -r file; do
echo "::error:: $file"
done
exit 1
fi
if [ "${#blocked[@]}" -gt 0 ]; then
echo "::error::Script files found in dev tool directories:"
for file in "${blocked[@]}"; do
echo "::error:: $file"
done
echo "Remove each file, or add a reviewed file to allowed-scripts."
exit 1
fi

echo "No executable script files found."
echo "No unexpected script files found."

- name: Check for suspicious commands
if: steps.detect.outputs.found == 'true'
shell: bash
env:
DIRS: ${{ steps.detect.outputs.dirs }}
STRICT: ${{ inputs.strict }}
run: |
# Execution and network patterns that have no business in
# editor or tool configuration files. Every pattern is anchored
# on word boundaries so that "async " does not match "nc ".
suspicious='\b(curl|wget|Invoke-WebRequest|iwr|powershell|pwsh|certutil|base64|ncat|socat|npx)\b|\b(ba)?sh -c\b|\bchmod \+x\b|\bnc +\S|\bpython3? -c\b|\bnode -e\b|\beval\('
suspect_files=$(grep -Erl "$suspicious" $DIRS 2>/dev/null)
# Allowed scripts are scanned too. The allowlist permits the file, not
# every command in it.
- name: Check for suspicious commands
if: steps.detect.outputs.found == 'true'
shell: bash
env:
DIRS: ${{ steps.detect.outputs.dirs }}
STRICT: ${{ inputs.strict }}
run: |
# Execution and network patterns that have no business in
# editor or tool configuration files. Every pattern is anchored
# on word boundaries so that "async " does not match "nc ".
suspicious='\b(curl|wget|Invoke-WebRequest|iwr|powershell|pwsh|certutil|base64|ncat|socat|npx)\b|\b(ba)?sh -c\b|\bchmod \+x\b|\bnc +\S|\bpython3? -c\b|\bnode -e\b|\beval\('
# grep exits 1 when nothing matches. `|| true` keeps `bash -e` running.
# shellcheck disable=SC2086
suspect_files=$(grep -Erl "$suspicious" $DIRS 2>/dev/null || true)

level=warning
[ "$STRICT" = "true" ] && level=error
level=warning
[ "$STRICT" = "true" ] && level=error

if [ -n "$suspect_files" ]; then
echo "::${level}::Suspicious commands found in dev tool configs:"
echo "$suspect_files" | while IFS= read -r file; do
echo "::${level}:: $file"
grep -En "$suspicious" "$file" | while IFS= read -r match; do
echo "::${level}:: $match"
done
done
if [ "$STRICT" = "true" ]; then
exit 1
fi
echo "Review the matches above. Set strict: true to fail on them."
exit 0
fi
if [ -n "$suspect_files" ]; then
echo "::${level}::Suspicious commands found in dev tool configs:"
echo "$suspect_files" | while IFS= read -r file; do
echo "::${level}:: $file"
grep -En "$suspicious" "$file" | while IFS= read -r match; do
echo "::${level}:: $match"
done
done
if [ "$STRICT" = "true" ]; then
exit 1
fi
echo "Review the matches above. Set strict: true to fail on them."
exit 0
fi

echo "No suspicious commands found."
echo "No suspicious commands found."
13 changes: 9 additions & 4 deletions .github/actions/verify-npm-hardening/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,17 +67,22 @@ steps:

**Organisation-wide usage** (recommended):

The action is published from the public `scify/.github` repository. Reference it by the `v1` tag:
In a SciFY repository, call the reusable `security.yml` workflow. It runs this
action with the other security checks. See the
[workflow guide](../../workflows/README.md#security).

To use the action on its own, pin it to a full commit SHA. The organisation
requires this for every action, including actions from `scify/.github`. A tag
such as `@v0.1` fails. Get the SHA of a release with
`git ls-remote https://github.com/scify/.github refs/tags/v0.1`:

```yaml
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: scify/.github/.github/actions/verify-npm-hardening@v0.1
- uses: scify/.github/.github/actions/verify-npm-hardening@<commit-sha> # v0.1
- run: npm ci
```

The reusable `security.yml` workflow in the same repository already runs this action. Call that workflow instead when you want the full set of checks.

## Inputs

| Name | Default | Description |
Expand Down
4 changes: 3 additions & 1 deletion .github/actions/verify-npm-hardening/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ runs:
errors=$((errors + 1))
fi

age=$(grep '^min-release-age=' .npmrc | cut -d= -f2)
# grep exits 1 when the setting is missing. `|| true` keeps
# `bash -e -o pipefail` running, so the error below is printed.
age=$(grep '^min-release-age=' .npmrc | cut -d= -f2 || true)
if ! [ "$age" -ge "$MIN_AGE" ] 2>/dev/null; then
echo "::error::Missing or invalid .npmrc setting: min-release-age must be a number >= ${MIN_AGE}"
errors=$((errors + 1))
Expand Down
8 changes: 5 additions & 3 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@
version: 2

updates:
# Reads `uses:` lines in .github/workflows/*.yml. For this ecosystem `/`
# means .github/workflows, not the repository root.
# `/` means .github/workflows, not the repository root. Composite actions
# are not covered by `/`, so each action folder is listed as well.
- package-ecosystem: github-actions
directory: /
directories:
- /
- /.github/actions/*
schedule:
interval: weekly
day: monday
Expand Down
Loading
Loading