diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..b7800f6 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,19 @@ +changelog: + exclude: + labels: + - ignore-for-release + authors: + - octocat + categories: + - title: Breaking Changes 🛠 + labels: + - breaking + - title: New Features 🎉 + labels: + - feature + - title: Fixes 🔧 + labels: + - fix + - title: Other Changes + labels: + - "*" diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml new file mode 100644 index 0000000..d82346f --- /dev/null +++ b/.github/workflows/bump-version.yml @@ -0,0 +1,29 @@ +name: Bump version + +on: + workflow_dispatch: +jobs: + bump_version: + if: "!startsWith(github.event.head_commit.message, 'bump:') && github.ref == 'refs/heads/main'" + runs-on: ubuntu-latest + name: "Bump version and create changelog with commitizen" + steps: + - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + id: app-token + with: + app-id: ${{ vars.ELEMENTSINTERACTIVE_BOT_APP_ID }} + private-key: ${{ secrets.ELEMENTSINTERACTIVE_BOT_PRIVATE_KEY }} + - uses: actions/checkout@@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + ref: ${{ github.head_ref }} + # Make sure the value of GITHUB_TOKEN will not be persisted in repo's config + persist-credentials: false + - id: cz + name: Create bump and changelog + uses: commitizen-tools/commitizen-action@5b0848cd060263e24602d1eba03710e056ef7711 # v0.24.0 + with: + github_token: ${{ steps.app-token.outputs.token }} + - name: Print Version + run: echo "Bumped to version ${{ steps.cz.outputs.version }}" diff --git a/.github/workflows/conventional-label.yaml b/.github/workflows/conventional-label.yaml new file mode 100644 index 0000000..159a4b5 --- /dev/null +++ b/.github/workflows/conventional-label.yaml @@ -0,0 +1,12 @@ +on: + pull_request_target: + branches: ["main"] + +name: conventional-release-labels +jobs: + label: + runs-on: ubuntu-latest + steps: + - uses: bcoe/conventional-release-labels@886f696738527c7be444262c327c89436dfb95a8 #v1.3.1 + with: + type_labels: '{"feat": "feature", "fix": "fix", "BREAKING CHANGE": "breaking", "ci": "CI", "build": "build", "refactor": "refactor", "test": "test"}' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..eb7f6d0 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,23 @@ +# This workflow will check our code for having a proper format, as well as the commit message to meet the expected ones + +name: Lint + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +jobs: + lint-commit: + runs-on: ubuntu-latest + name: "Lint commit message" + container: + image: commitizen/commitizen:4.8.3@sha256:08a078c52b368f85f34257a66e10645ee74d8cbe9b471930b80b2b4e95a9bd4a + steps: + - name: Check out + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - name: Check commit message + run: | + git config --global --add safe.directory . + cz check --rev-range HEAD diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..a9494e2 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,22 @@ +name: Publish + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - name: Release + uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 + with: + generate_release_notes: true + make_latest: true + token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..371eb8d --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +@sdn4z @scastlara \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..79fbcaf --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# Twyn Action + +A GitHub Action that runs security checks against dependency typosquatting attacks using [Twyn](https://github.com/elementsinteractive/twyn). + +![Twyn Action Results](assets/twyn-action-results.png) + + +## What is Twyn? + +Twyn is a security tool that analyzes your project dependencies to detect potential typosquatting attacks - when malicious packages have names similar to legitimate ones to trick developers into installing them. + +## Examples + +### Basic Security Check + +```yaml + pull_request: + branches: [ main ] + +- name: Run Twyn Security Check + uses: elementsinteractive/twyn-action@v1 + with: + publish: true + github-token: ${{ secrets.GITHUB_TOKEN }} +``` + +## Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `dependency-file` | Dependency file(s) to analyze (comma-separated) | No | Auto-detect | +| `table` | Display results in table format (requires version >=v6) | No | `false` | +| `json` | Display results in JSON format | No | `false` | +| `publish` | Publish results as PR comments. It must run in a PR context | No | `false` | +| `github-token` | GitHub token for publishing comments | No | - | +| `recursive` | Recursively search for dependency files | No | `false` | +| `selector-method` | Method for selecting typosquats (`first-letter`, `nearby-letter`, `all`) | No | - | +| `v` | Enable verbose output | No | `false` | +| `vv` | Enable extra verbose output | No | `false` | +| `version` | Twyn version to use | No | `latest` | + +## Outputs + +| Output | Description | +|--------|-------------| +| `results` | Raw output from twyn scan | +| `exit-code` | Exit code | + +## Publishing Results to PR + +When `publish: true` is enabled, the action will automatically post a comment to the Pull Request with a formatted table showing any security findings. + +**âš ī¸ Important: Publishing only works when the workflow runs in a Pull Request context.** Make sure your workflow is triggered by `pull_request` events, not just `push` or `workflow_dispatch`. + +This requires: +- `table: true` (automatically enabled when publish is true) +- `version: "v6"` or higher (table format requires Twyn v6+) +- `github-token` to be provided +- The workflow to run on a Pull Request event (`on: pull_request`) + +The PR comment will include a detailed table with information about potential typosquatting packages found. diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..57e82cc --- /dev/null +++ b/action.yml @@ -0,0 +1,255 @@ +name: "Twyn action" +description: "Security tool against dependency typosquatting attacks" +author: "Elements Interactive" + +branding: + icon: "search" + color: "blue" + +inputs: + github-token: + description: "Token needed to publish results to the PR." + required: false + + config: + description: "Path to the config file" + required: false + + dependency-file: + description: "Dependency file(s) to analyze. Comma-separated if multiple. By default, twyn will search in the current directory for supported files, but this option will override that behavior." + required: false + + selector-method: + description: "Which method twyn should use to select possible typosquats. 'first-letter' only compares dependencies that share the first letter, 'nearby-letter' compares against dependencies whose first letter is nearby in an English keyboard. 'all' compares the given dependencies against all of those in the reference." + required: false + + no-track: + description: "Do not show the progress bar while processing packages" + required: false + default: "false" + + json: + description: "Display the results in json format. It implies no-track. Mutually exclusive with --table." + required: false + default: "false" + + table: + description: "Display the results in a table format. It implies no-track. Mutually exclusive with --json." + required: false + default: "false" + + recursive: + description: "Recursively look for files when trying to locate them automatically. Ignored if dependency-file is given." + required: false + default: "false" + + pypi-source: + description: "Alternative PyPI source URL to use for fetching trusted packages" + required: false + + npm-source: + description: "Alternative npm source URL to use for fetching trusted packages" + required: false + + v: + description: "Enable verbose output (-v)" + required: false + default: "false" + + vv: + description: "Enable extra verbose output (-vv)" + required: false + default: "false" + + version: + description: "Twyn version (latest, v1.0.0, etc.)" + required: false + default: "latest" + + publish: + description: "Whether to publish the twyn results as PR comments (requires table format)" + required: false + default: "false" + +outputs: + results: + description: "Raw output from twyn scan" + value: ${{ steps.twyn-scan.outputs.results }} + exit-code: + description: "Exit code from twyn scan" + value: ${{ steps.twyn-scan.outputs.exit-code }} + +runs: + using: "composite" + steps: + - name: Run Twyn Security Check + id: twyn-scan + shell: bash + run: | + # Validate input combinations + if [ "${{ inputs.publish }}" = "true" ] && [ "${{ inputs.json }}" = "true" ]; then + echo "❌ Error: 'publish' and 'json' cannot be used together." + echo " Publishing requires table format, but JSON format was requested." + echo " Please use either 'publish: true' with 'table: true' or use 'json: true' without publishing." + exit 1 + fi + + if [ "${{ inputs.json }}" = "true" ] && [ "${{ inputs.table }}" = "true" ]; then + echo "❌ Error: 'json' and 'table' are mutually exclusive." + echo " Please choose either JSON or table format, not both." + exit 1 + fi + + + # Build arguments as an array for safety (avoids word-splitting issues) + ARGS=() + + # Optional config file + if [ -n "${{ inputs.config }}" ]; then + ARGS+=(--config "${{ inputs.config }}") + fi + + # Dependency files (multiple allowed) + if [ -n "${{ inputs.dependency-file }}" ]; then + IFS=',' read -ra DEPENDENCY_FILES <<< "${{ inputs.dependency-file }}" + for file in "${DEPENDENCY_FILES[@]}"; do + if [ -n "$file" ]; then + ARGS+=(--dependency-file "$file") + fi + done + fi + + # Selector method + if [ -n "${{ inputs.selector-method }}" ]; then + ARGS+=(--selector-method "${{ inputs.selector-method }}") + fi + + # Boolean flags + + if [ "${{ inputs.no-track }}" = "true" ]; then + ARGS+=(--no-track) + fi + + if [ "${{ inputs.json }}" = "true" ]; then + ARGS+=(--json) + fi + + # Force table format when publishing + if [ "${{ inputs.publish }}" = "true" ] || [ "${{ inputs.table }}" = "true" ]; then + VERSION="${{ inputs.version }}" + # Check if version is in format vX.Y.Z or just latest + if [[ "$VERSION" =~ ^v[0-9]+(\.[0-9]+)*$ ]]; then + # Extract version number (remove 'v' prefix) + VERSION_NUM=${VERSION#v} + MAJOR_VERSION=$(echo "$VERSION_NUM" | cut -d. -f1) + if [ "$MAJOR_VERSION" -lt 6 ]; then + echo "❌ Error: Table format requires Twyn version >= v6." + echo " Current version: $VERSION" + echo " Please set 'version: \"v6\"' or higher to use table format and to publish to a PR." + exit 1 + fi + elif [ "$VERSION" != "latest" ]; then + echo "❌ Error: Invalid version format '$VERSION'." + echo " Expected format: 'vX.Y.Z' (e.g., 'v6.0.0') or 'latest'." + echo " Table format and publishing require version >= v6." + exit 1 + fi + + # If the check was successful, add the table arg + ARGS+=(--table) + fi + + if [ "${{ inputs.recursive }}" = "true" ]; then + ARGS+=(--recursive) + fi + + # Source URLs + if [ -n "${{ inputs.pypi-source }}" ]; then + ARGS+=(--pypi-source "${{ inputs.pypi-source }}") + fi + + if [ -n "${{ inputs.npm-source }}" ]; then + ARGS+=(--npm-source "${{ inputs.npm-source }}") + fi + + # Verbose mode + if [ "${{ inputs.vv }}" = "true" ]; then + ARGS+=(-vv) + elif [ "${{ inputs.v }}" = "true" ]; then + ARGS+=(-v) + fi + + + # Run twyn using Docker and capture output and exit code + # Use 'set +e' to prevent script from exiting on non-zero exit codes + set +e + TWYN_OUTPUT=$(docker run --rm \ + -v "${{ github.workspace }}:/results" \ + -w /results \ + elementsinteractive/twyn:${{ inputs.version }} run \ + "${ARGS[@]}" 2>/dev/null) + TWYN_EXIT_CODE=$? + set -e + + # Display output in action logs + echo "$TWYN_OUTPUT" + + # Set action outputs + echo "results<> $GITHUB_OUTPUT + echo "$TWYN_OUTPUT" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + echo "exit-code=$TWYN_EXIT_CODE" >> $GITHUB_OUTPUT + + # Create PR comment with twyn results + if [ "${{ inputs.publish }}" = "true" ] && [ $TWYN_EXIT_CODE -ge 1 ]; then + # Check if github-token is provided + if [ -z "${{ inputs.github-token }}" ]; then + echo "❌ Error: github-token is required when publish is enabled. Skipping..." + exit $TWYN_EXIT_CODE + fi + + # Check if we're in a Pull Request context + if [ "${{ github.event_name }}" != "pull_request" ] || [ -z "${{ github.event.pull_request.number }}" ]; then + echo "❌ Error: Publishing the results to the PR only works in Pull Request context." + echo " Current event: ${{ github.event_name }}" + echo " To enable publishing, make sure your workflow is triggered by 'pull_request' events." + echo " Example:" + echo " on:" + echo " pull_request:" + echo " branches: [main]" + exit $TWYN_EXIT_CODE + fi + + # Create comment content with proper formatting + echo "## đŸ›Ąī¸ Twyn Detection Results" > comment.md + echo "" >> comment.md + echo '```' >> comment.md + # Process the output to handle escape sequences properly + echo "$TWYN_OUTPUT" | sed 's/\\n/\n/g' >> comment.md + echo '```' >> comment.md + + curl -X POST \ + -H "Authorization: token ${{ inputs.github-token}}" \ + -H "Accept: application/vnd.github.v3+json" \ + -d "$(cat comment.md | jq -Rs '{"body": .}')" \ + "https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" + + if [ $? -eq 0 ]; then + echo "✅ Successfully posted comment to PR" + else + echo "❌ Failed to post comment to PR" + fi + + else + echo "â„šī¸ Publish to PR is disabled (publish: ${{ inputs.publish }})" + fi + + # Set final exit code for the action + # Exit with 0 if we're just reporting findings (exit code 1) + # Exit with the actual code for real errors (exit codes > 1) + if [ $TWYN_EXIT_CODE -gt 1 ]; then + exit $TWYN_EXIT_CODE + else + exit 0 + fi diff --git a/assets/twyn-action-results.png b/assets/twyn-action-results.png new file mode 100644 index 0000000..d2f94be Binary files /dev/null and b/assets/twyn-action-results.png differ