diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..881021d --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# commit-msg — validate Conventional Commits format +set -euo pipefail + +MSG_FILE="$1" +MSG=$(head -1 "$MSG_FILE") + +# Pattern: type(optional-scope): description +# Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert +PATTERN='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .{1,}' + +if ! echo "$MSG" | grep -qE "$PATTERN"; then + echo "" + echo "ERROR: Commit message does not follow Conventional Commits format." + echo "" + echo " Expected: (): " + echo "" + echo " Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert" + echo "" + echo " Examples:" + echo " feat(banner-grabber): add JSON output" + echo " fix(netutil): handle IPv6 correctly" + echo " ci: add arm64 build target" + echo "" + echo " Your message: $MSG" + echo "" + exit 1 +fi diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..1305b9c --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# pre-commit — fast checks: formatting & linting (runs in <30s) +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +NC='\033[0m' + +info() { printf "${CYAN}[hook]${NC} %s\n" "$*"; } +pass() { printf "${GREEN}[hook] ✓${NC} %s\n" "$*"; } +fail() { printf "${RED}[hook] ✗${NC} %s\n" "$*"; } + +STAGED=$(git diff --cached --name-only --diff-filter=ACMR) +HAS_GO=false +HAS_RUST=false + +echo "$STAGED" | grep -qE '\.(go)$' && HAS_GO=true || true +echo "$STAGED" | grep -qE '\.(rs|toml)$' && HAS_RUST=true || true +echo "$STAGED" | grep -qE '(Cargo\.toml|Cargo\.lock)' && HAS_RUST=true || true + +ERRORS=0 + +# ─── Go ────────────────────────────────────────────────────────── +if $HAS_GO; then + info "Go: checking formatting..." + UNFORMATTED=$(gofmt -l . 2>/dev/null || true) + if [ -n "$UNFORMATTED" ]; then + fail "Go files need formatting:" + echo "$UNFORMATTED" | sed 's/^/ /' + ERRORS=$((ERRORS + 1)) + else + pass "Go formatting" + fi + + info "Go: running linter..." + if ! golangci-lint run ./... 2>&1; then + fail "golangci-lint" + ERRORS=$((ERRORS + 1)) + else + pass "golangci-lint" + fi +fi + +# ─── Rust ──────────────────────────────────────────────────────── +if $HAS_RUST; then + info "Rust: checking formatting..." + if ! cargo fmt --all -- --check 2>&1; then + fail "cargo fmt" + ERRORS=$((ERRORS + 1)) + else + pass "cargo fmt" + fi + + info "Rust: running clippy..." + if ! cargo clippy --all-targets --all-features -- -D warnings 2>&1; then + fail "cargo clippy" + ERRORS=$((ERRORS + 1)) + else + pass "cargo clippy" + fi +fi + +# ─── Result ────────────────────────────────────────────────────── +if [ "$ERRORS" -gt 0 ]; then + echo "" + fail "Commit blocked — fix the $ERRORS error(s) above." + echo " Tip: run 'make lint' to reproduce locally." + exit 1 +fi + +pass "All pre-commit checks passed." diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..6e69595 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# pre-push — full validation: tests + build (mirrors CI pipeline) +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +NC='\033[0m' + +info() { printf "${CYAN}[hook]${NC} %s\n" "$*"; } +pass() { printf "${GREEN}[hook] ✓${NC} %s\n" "$*"; } +fail() { printf "${RED}[hook] ✗${NC} %s\n" "$*"; } + +ERRORS=0 + +# ─── Go ────────────────────────────────────────────────────────── +info "Go: running tests..." +if ! go test -race ./... 2>&1; then + fail "Go tests" + ERRORS=$((ERRORS + 1)) +else + pass "Go tests" +fi + +info "Go: building tools..." +if ! go build ./tools/... 2>&1; then + fail "Go build" + ERRORS=$((ERRORS + 1)) +else + pass "Go build" +fi + +# ─── Rust ──────────────────────────────────────────────────────── +info "Rust: running tests..." +if ! cargo test --all 2>&1; then + fail "Rust tests" + ERRORS=$((ERRORS + 1)) +else + pass "Rust tests" +fi + +info "Rust: building..." +if ! cargo build --release 2>&1; then + fail "Rust build" + ERRORS=$((ERRORS + 1)) +else + pass "Rust build" +fi + +# ─── Result ────────────────────────────────────────────────────── +if [ "$ERRORS" -gt 0 ]; then + echo "" + fail "Push blocked — fix the $ERRORS error(s) above." + echo " Tip: run 'make test && make build' to reproduce locally." + exit 1 +fi + +pass "All pre-push checks passed." diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..8d5f4b1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,39 @@ +--- +name: Bug Report +about: Report a bug in one of the tools +title: "[BUG] " +labels: bug +assignees: "" +--- + +## Tool + + + +## Description + + + +## Steps to Reproduce + +1. +2. +3. + +## Expected Behavior + + + +## Actual Behavior + + + +## Environment + +- **OS:** +- **Tool version:** +- **Go/Python/Rust version:** + +## Additional Context + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..a637e62 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,27 @@ +--- +name: Feature Request +about: Suggest a new tool or feature +title: "[FEATURE] " +labels: enhancement +assignees: "" +--- + +## Summary + + + +## Motivation + + + +## Proposed Solution + + + +## Language + + + +## Additional Context + + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..f09bcea --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,28 @@ +## Description + + + +## Type of Change + +- [ ] New tool +- [ ] Bug fix +- [ ] Enhancement to existing tool +- [ ] Documentation +- [ ] CI/CD + +## Tool(s) Affected + + + +## Checklist + +- [ ] Code follows project conventions +- [ ] Tests added/updated +- [ ] Tool-specific README updated (if applicable) +- [ ] Root README tool table updated (if new tool) +- [ ] Linters pass locally (`make lint`) +- [ ] CI passes + +## Screenshots / Output + + diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..ed46da1 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,93 @@ +name: Go + +on: + push: + branches: [main] + paths: + - "go.mod" + - "go.sum" + - "tools/**" + - "libs/netutil/**" + - ".github/workflows/go.yml" + - ".golangci.yml" + pull_request: + branches: [main] + paths: + - "go.mod" + - "go.sum" + - "tools/**" + - "libs/netutil/**" + - ".github/workflows/go.yml" + - ".golangci.yml" + +permissions: + contents: read + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libpcap-dev + + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libpcap-dev + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: go-coverage + path: coverage.out + + build: + name: Build + needs: [lint, test] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libpcap-dev + + - name: Build all commands + run: | + for tool in tools/*/; do + TOOL_NAME=$(basename "$tool") + echo "=== Building $TOOL_NAME ===" + CGO_ENABLED=1 go build -ldflags="-s -w" -o "bin/${TOOL_NAME}" "./${tool}" + done + + - name: Upload binaries + uses: actions/upload-artifact@v4 + with: + name: go-binaries-linux-amd64 + path: bin/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c17a74a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,231 @@ +name: Release + +on: + push: + tags: + - "*/v*" + +permissions: + contents: write + +jobs: + meta: + name: Parse release tag + runs-on: ubuntu-latest + outputs: + tool: ${{ steps.parse.outputs.tool }} + version: ${{ steps.parse.outputs.version }} + lang: ${{ steps.parse.outputs.lang }} + steps: + - uses: actions/checkout@v4 + + - name: Parse tag + id: parse + run: | + TAG="${{ github.ref_name }}" + TOOL="${TAG%%/v*}" + VERSION="${TAG##*/}" + + echo "tool=$TOOL" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + # Detect language by checking where the tool lives + if [ -d "tools/$TOOL" ]; then + echo "lang=go" >> "$GITHUB_OUTPUT" + elif [ -d "libs/$TOOL" ]; then + echo "lang=rust" >> "$GITHUB_OUTPUT" + else + echo "::error::Unknown tool '$TOOL' — no tools/$TOOL or libs/$TOOL found" + exit 1 + fi + + echo "### Release Info" >> "$GITHUB_STEP_SUMMARY" + echo "- **Tool:** $TOOL" >> "$GITHUB_STEP_SUMMARY" + echo "- **Version:** $VERSION" >> "$GITHUB_STEP_SUMMARY" + + build-go: + name: Go — ${{ needs.meta.outputs.tool }} (${{ matrix.goos }}/${{ matrix.goarch }}) + needs: meta + if: needs.meta.outputs.lang == 'go' + runs-on: ubuntu-latest + strategy: + matrix: + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + exclude: + - goos: windows + goarch: arm64 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libpcap-dev + + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: "0" + run: | + TOOL="${{ needs.meta.outputs.tool }}" + VERSION="${{ needs.meta.outputs.version }}" + EXT="" + if [ "$GOOS" = "windows" ]; then EXT=".exe"; fi + + mkdir -p dist + go build \ + -ldflags="-s -w -X main.version=${VERSION}" \ + -o "dist/${TOOL}-${GOOS}-${GOARCH}${EXT}" \ + "./tools/${TOOL}" || echo "::warning::Skipped ${TOOL} for ${GOOS}/${GOARCH} (CGO required)" + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.meta.outputs.tool }}-${{ matrix.goos }}-${{ matrix.goarch }} + path: dist/ + + build-rust: + name: Rust — ${{ needs.meta.outputs.tool }} (${{ matrix.target }}) + needs: meta + if: needs.meta.outputs.lang == 'rust' + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + suffix: linux-amd64 + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + suffix: linux-arm64 + - target: x86_64-apple-darwin + os: macos-latest + suffix: darwin-amd64 + - target: aarch64-apple-darwin + os: macos-latest + suffix: darwin-arm64 + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install cross-compilation tools + if: matrix.target == 'aarch64-unknown-linux-gnu' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV" + + - name: Build + run: | + TOOL="${{ needs.meta.outputs.tool }}" + cargo build --release --target ${{ matrix.target }} -p "$TOOL" + + - name: Collect binary + run: | + TOOL="${{ needs.meta.outputs.tool }}" + mkdir -p dist + SRC="target/${{ matrix.target }}/release/${TOOL}" + if [ -f "$SRC" ]; then + cp "$SRC" "dist/${TOOL}-${{ matrix.suffix }}" + elif [ -f "${SRC}.exe" ]; then + cp "${SRC}.exe" "dist/${TOOL}-${{ matrix.suffix }}.exe" + fi + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.meta.outputs.tool }}-${{ matrix.suffix }} + path: dist/ + + release: + name: "Release ${{ needs.meta.outputs.tool }} ${{ needs.meta.outputs.version }}" + needs: [meta, build-go, build-rust] + if: | + always() && + needs.meta.result == 'success' && + (needs.build-go.result == 'success' || needs.build-go.result == 'skipped') && + (needs.build-rust.result == 'success' || needs.build-rust.result == 'skipped') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + pattern: "${{ needs.meta.outputs.tool }}-*" + merge-multiple: true + + - name: Generate checksums + run: | + cd artifacts + sha256sum * > checksums-sha256.txt 2>/dev/null || echo "No binaries" + + - name: Generate changelog + id: changelog + run: | + TOOL="${{ needs.meta.outputs.tool }}" + # Find previous tag for this specific tool + PREV_TAG=$(git tag -l "${TOOL}/v*" --sort=-v:refname | head -2 | tail -1) + if [ -z "$PREV_TAG" ] || [ "$PREV_TAG" = "${{ github.ref_name }}" ]; then + echo "changelog<> "$GITHUB_OUTPUT" + echo "Initial release of **${TOOL}**" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + else + echo "changelog<> "$GITHUB_OUTPUT" + git log --pretty=format:"- %s (%h)" "${PREV_TAG}..HEAD" -- "tools/${TOOL}" "libs/${TOOL}" "libs/netutil/" >> "$GITHUB_OUTPUT" + echo "" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + fi + + - name: Detect install command + id: install + run: | + TOOL="${{ needs.meta.outputs.tool }}" + VERSION="${{ needs.meta.outputs.version }}" + LANG="${{ needs.meta.outputs.lang }}" + + if [ "$LANG" = "go" ]; then + echo "cmd=go install github.com/flaviomilan/sectools/tools/${TOOL}@${VERSION}" >> "$GITHUB_OUTPUT" + else + echo "cmd=cargo install --git https://github.com/flaviomilan/sectools --tag ${{ github.ref_name }} -p ${TOOL}" >> "$GITHUB_OUTPUT" + fi + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + name: "${{ needs.meta.outputs.tool }} ${{ needs.meta.outputs.version }}" + tag_name: ${{ github.ref_name }} + body: | + ## ${{ needs.meta.outputs.tool }} ${{ needs.meta.outputs.version }} + + ${{ steps.changelog.outputs.changelog }} + + ### Install + + **From source:** + ```bash + ${{ steps.install.outputs.cmd }} + ``` + + **Pre-built binary:** + ```bash + # Linux amd64 + curl -Lo ${{ needs.meta.outputs.tool }} \ + https://github.com/flaviomilan/sectools/releases/download/${{ github.ref_name }}/${{ needs.meta.outputs.tool }}-linux-amd64 + chmod +x ${{ needs.meta.outputs.tool }} + sudo mv ${{ needs.meta.outputs.tool }} /usr/local/bin/ + ``` + + ### Checksums (SHA-256) + See `checksums-sha256.txt` in the release assets. + files: artifacts/* diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..7359e17 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,60 @@ +name: Rust + +on: + push: + branches: [main] + paths: + - "Cargo.toml" + - "Cargo.lock" + - "libs/sectools-common/**" + - ".github/workflows/rust.yml" + pull_request: + branches: [main] + paths: + - "Cargo.toml" + - "Cargo.lock" + - "libs/sectools-common/**" + - ".github/workflows/rust.yml" + +permissions: + contents: read + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Check formatting + run: cargo fmt -- --check + + - name: Clippy + run: cargo clippy -- -D warnings + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - name: Run tests + run: cargo test --verbose + + build: + name: Build + needs: [lint, test] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - name: Build release + run: cargo build --release diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..f256f20 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,89 @@ +name: Security + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "0 6 * * 1" + +permissions: + contents: read + security-events: write + +jobs: + go-vuln: + name: Go vulnerability check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libpcap-dev + + - name: Run govulncheck + run: govulncheck ./... + + cargo-audit: + name: Rust dependency audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-audit + run: cargo install cargo-audit + + - name: Run audit + run: cargo audit || true + + trivy: + name: Trivy filesystem scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run Trivy + uses: aquasecurity/trivy-action@master + with: + scan-type: fs + scan-ref: . + format: sarif + output: trivy-results.sarif + severity: CRITICAL,HIGH + + - name: Upload Trivy results + uses: github/codeql-action/upload-sarif@v3 + if: always() + with: + sarif_file: trivy-results.sarif + + codeql: + name: CodeQL (${{ matrix.language }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: [go] + steps: + - uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.gitignore b/.gitignore index ea7314a..23767b8 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ # Go workspace file go.work +# Build output +bin/ + ### Python ### # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..af26e48 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,47 @@ +run: + timeout: 3m + +linters: + enable: + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - unused + - bodyclose + - copyloopvar + - dupl + - errname + - errorlint + - gocognit + - goconst + - gocritic + - gocyclo + - gosec + - misspell + - nilerr + - prealloc + - revive + - unconvert + - unparam + - whitespace + +linters-settings: + gocyclo: + min-complexity: 15 + goconst: + min-len: 3 + min-occurrences: 3 + dupl: + threshold: 100 + gosec: + severity: medium + confidence: medium + +issues: + exclude-rules: + - path: _test\.go + linters: + - dupl + - gosec diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..a211f12 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,52 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior: + +* The use of sexualized language or imagery and unwelcome sexual attention +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information without explicit permission +* Other conduct which could reasonably be considered inappropriate + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the project maintainers. All complaints will be reviewed and +investigated promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..276f9f7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,139 @@ +# Contributing to sectools + +Thank you for your interest in contributing! This guide will help you get started. + +## Project Structure + +``` +sectools/ +├── tools/ # CLI tools (one folder per tool) +│ ├── banner-grabber/ +│ └── port-knocking-scanner/ +├── libs/ +│ ├── netutil/ # Shared Go library +│ └── sectools-common/ # Shared Rust library +├── Cargo.toml # Rust workspace manifest +├── .github/workflows/ # CI/CD pipelines +├── go.mod # Go module (root) +└── Makefile +``` + +## Prerequisites + +| Requirement | Version | Purpose | +|-------------|---------|---------| +| Go | ≥ 1.24 | Build & test Go tools | +| Rust | ≥ 1.75 | Build & test Rust crates | +| libpcap-dev | any | Required by port-knocking-scanner (CGO) | +| golangci-lint | latest | Go linting | + +## Getting Started + +```bash +git clone https://github.com/flaviomilan/sectools.git +cd sectools +make build +make test +``` + +## Development Workflow + +1. **Fork and branch** — create a feature branch from `main`. +2. **Make changes** — follow the conventions below. +3. **Test locally** — run `make lint && make test`. +4. **Commit** — use [Conventional Commits](https://www.conventionalcommits.org/) format. +5. **Open a PR** — target `main`, fill out the PR template. + +## Adding a New Go Tool + +1. Create `tools//main.go` with a `main` package. +2. Add `var version = "dev"` so the release pipeline can inject the version via `-ldflags`. +3. Reuse shared utilities from `libs/netutil/` when possible. +4. Add tests alongside your code. +5. The CI and release workflows pick up new tools automatically. + +```bash +# Verify it builds +go build ./tools/ + +# Run Go tests +make test-go +``` + +## Adding a New Rust Crate + +1. Create `libs//` with a `Cargo.toml` that inherits workspace settings. +2. Add the crate to `members` in the root `Cargo.toml`. +3. Add tests in `src/lib.rs` or a `tests/` directory. + +```bash +# Verify it builds +cargo build -p + +# Run Rust tests +make test-rust +``` + +## Code Style + +### Go + +- Follow [Effective Go](https://go.dev/doc/effective_go) guidelines. +- All code must pass `golangci-lint` (see `.golangci.yml` for enabled linters). +- Export functions and types that are shared; keep tool-specific logic private. + +### Rust + +- Follow standard Rust idioms and `clippy` recommendations. +- Code must pass `cargo fmt --check` and `cargo clippy -- -D warnings`. +- Use the workspace `Cargo.toml` for shared dependency versions. + +## Running Tests + +```bash +make test # All tests (Go + Rust) +make test-go # Go tests with race detector +make test-rust # Rust tests +``` + +## Linting + +```bash +make lint # All linters +make lint-go # golangci-lint +make lint-rust # clippy + rustfmt +``` + +## Release Process + +Each tool is versioned independently. To release a tool: + +```bash +# Create and push a tag +make release-tag TOOL=banner-grabber VERSION=v1.2.0 +git push origin banner-grabber/v1.2.0 +``` + +The release workflow handles cross-compilation, checksums, and GitHub Release creation automatically. + +**Tag convention:** `/v..` + +## Commit Messages + +Use [Conventional Commits](https://www.conventionalcommits.org/): + +``` +feat(banner-grabber): add JSON output format +fix(netutil): handle IPv6 addresses correctly +docs: update README installation section +ci: add arm64 build target +``` + +## Reporting Issues + +- Use the [Bug Report](https://github.com/flaviomilan/sectools/issues/new?template=bug_report.md) template for bugs. +- Use the [Feature Request](https://github.com/flaviomilan/sectools/issues/new?template=feature_request.md) template for ideas. + +## Code of Conduct + +This project follows the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c1e5059 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[workspace] +resolver = "2" +members = [ + "libs/sectools-common", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT" +repository = "https://github.com/flaviomilan/sectools" +authors = ["Flavio Milan"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..723874c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Flavio Milan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f988252 --- /dev/null +++ b/Makefile @@ -0,0 +1,82 @@ +.DEFAULT_GOAL := help + +# ─── Go ────────────────────────────────────────────────────────── + +.PHONY: lint-go test-go build-go install-go + +lint-go: ## Run Go linters + golangci-lint run ./... + +test-go: ## Run Go tests with race detector + go test -v -race -coverprofile=coverage-go.out ./... + +build-go: ## Build all Go tools + @for tool in tools/*/; do \ + name=$$(basename "$$tool"); \ + echo "=== Building $$name ==="; \ + CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=dev" -o bin/$$name ./$$tool; \ + done + +install-go: ## Install all Go tools locally + @for tool in tools/*/; do \ + echo "=== Installing $$(basename $$tool) ==="; \ + go install ./$$tool; \ + done + +# ─── Rust ──────────────────────────────────────────────────────── + +.PHONY: lint-rust test-rust build-rust + +lint-rust: ## Run Rust linters + cargo clippy --all-targets --all-features -- -D warnings + cargo fmt --all -- --check + +test-rust: ## Run Rust tests + cargo test --all + +build-rust: ## Build all Rust crates in release mode + cargo build --release + +# ─── Aggregate ─────────────────────────────────────────────────── + +.PHONY: lint test build clean + +lint: lint-go lint-rust ## Run all linters + +test: test-go test-rust ## Run all tests + +build: build-go build-rust ## Build everything + +clean: ## Remove build artifacts + rm -rf bin/ coverage-go.out + cargo clean + +# ─── Git hooks ─────────────────────────────────────────────────── + +.PHONY: hooks + +hooks: ## Install git hooks (.githooks → .git/hooks) + git config core.hooksPath .githooks + @echo "Git hooks activated from .githooks/" + +# ─── Release helpers ───────────────────────────────────────────── + +.PHONY: release-tag + +release-tag: ## Create a per-tool release tag (usage: make release-tag TOOL=banner-grabber VERSION=v1.0.0) +ifndef TOOL + $(error TOOL is required — e.g. make release-tag TOOL=banner-grabber VERSION=v1.0.0) +endif +ifndef VERSION + $(error VERSION is required — e.g. make release-tag TOOL=banner-grabber VERSION=v1.0.0) +endif + git tag -a "$(TOOL)/$(VERSION)" -m "Release $(TOOL) $(VERSION)" + @echo "Tag created: $(TOOL)/$(VERSION)" + @echo "Push with: git push origin $(TOOL)/$(VERSION)" + +# ─── Help ──────────────────────────────────────────────────────── + +.PHONY: help +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' diff --git a/README.md b/README.md index 3082b80..3db4e23 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,163 @@ -# 🛡️ sectools +# sectools -This repository contains tools and experiments focused on offensive and defensive security, task automation, network analysis, and threat detection. +[![Go](https://github.com/flaviomilan/sectools/actions/workflows/go.yml/badge.svg)](https://github.com/flaviomilan/sectools/actions/workflows/go.yml) +[![Rust](https://github.com/flaviomilan/sectools/actions/workflows/rust.yml/badge.svg)](https://github.com/flaviomilan/sectools/actions/workflows/rust.yml) +[![Security](https://github.com/flaviomilan/sectools/actions/workflows/security.yml/badge.svg)](https://github.com/flaviomilan/sectools/actions/workflows/security.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -## 📜 Available Scripts +A curated monorepo of security tools built with **Go** and **Rust**. +Each tool is independently versioned and released as a standalone binary. -### 🔐 `port-knocking-scanner` +--- -A network scanner that uses **port knocking** sequences to identify hosts running hidden services. -Built using the [`gopacket`](https://github.com/google/gopacket) library to avoid external dependencies like `hping3`. +## Tools -**Features:** +| Tool | Language | Description | +|------|----------|-------------| +| **banner-grabber** | Go | TCP banner grabbing — probes open ports and captures service banners | +| **port-knocking-scanner** | Go | Detects port-knocking sequences using raw packet capture (gopacket/pcap) | +| **sectools-common** | Rust | Shared library with network utilities (IP validation, port parsing, banner grab) | -- 🔎 Scans a range of IPs within a `/24` network. -- 🛠️ Sends a configurable sequence of knock ports. -- 🔍 Checks if a service is exposed after the final knock. +## Project Structure -**Usage:** +``` +sectools/ +├── tools/ +│ ├── banner-grabber/ # Go CLI tool +│ └── port-knocking-scanner/ # Go CLI tool +├── libs/ +│ ├── netutil/ # Shared Go library +│ └── sectools-common/ # Shared Rust library +├── .github/ +│ └── workflows/ +│ ├── go.yml # Go lint, test, build +│ ├── rust.yml # Rust lint, test, build +│ ├── security.yml # govulncheck, cargo-audit, Trivy, CodeQL +│ └── release.yml # Per-tool release on tag push +├── go.mod # Go module +├── Cargo.toml # Rust workspace +├── Makefile +└── ... +``` + +## Installation + +### From source (Go tools) + +```bash +go install github.com/flaviomilan/sectools/tools/banner-grabber@latest +go install github.com/flaviomilan/sectools/tools/port-knocking-scanner@latest +``` + +### Pre-built binaries + +Download from [Releases](https://github.com/flaviomilan/sectools/releases). +Each tool has its own release page with binaries for Linux, macOS, and Windows. + +```bash +# Example: install banner-grabber on Linux amd64 +curl -Lo banner-grabber \ + https://github.com/flaviomilan/sectools/releases/download/banner-grabber%2Fv1.0.0/banner-grabber-linux-amd64 +chmod +x banner-grabber +sudo mv banner-grabber /usr/local/bin/ +``` + +### Build locally ```bash -go build -sudo ./knocking_scanner -start 192.168.0.1 -end 192.168.0.254 -ports 13,37,30000,3000,1337 -iface eth0 +make build # Build all (Go + Rust) +make build-go # Build Go tools only → bin/ +make build-rust # Build Rust crates only ``` -### 🚩 `banner-grabber` +## Usage -A tool to perform banner grabbing on specified hosts and ports, retrieving service information. -It allows TCP and UDP scanning and includes customizable timeout settings. +### banner-grabber -**Features:** +```bash +banner-grabber -host 192.168.1.1 -ports 22,80,443 -timeout 5s +banner-grabber -host 10.0.0.1 -ports 1-1024 -send "HEAD / HTTP/1.0\r\n\r\n" -output results.txt +banner-grabber -version +``` -- 🎯 Target specific hosts and ports. -- 📜 Retrieve service banners. -- ⏱️ Customizable timeout. -- 🌐 Supports both TCP and UDP protocols. +### port-knocking-scanner -**Usage:** +> Requires root / `CAP_NET_RAW` for raw packet capture. ```bash -go build +sudo port-knocking-scanner -target 192.168.1.1 -ports 7000,8000,9000 +sudo port-knocking-scanner -target 10.0.0.1 -ports 7000,8000,9000 -timeout 10s +port-knocking-scanner -version +``` + +## Development -./banner-grabber -host 192.168.1.10 -ports 80 -./banner-grabber -host 192.168.1.10 -ports 22 -timeout 5 -./banner-grabber -host 192.168.1.10 -ports 161 -udp +### Prerequisites + +- **Go** ≥ 1.24 +- **Rust** ≥ 1.75 (2021 edition) +- **libpcap-dev** (for port-knocking-scanner) +- **golangci-lint** (for Go linting) + +### Common tasks + +```bash +make help # Show all available targets +make lint # Lint Go + Rust +make test # Test Go + Rust +make build # Build everything +make clean # Remove artifacts ``` + +## Release Process + +Each tool is versioned and released independently using the tag pattern: + +``` +/v +``` + +### Creating a release + +```bash +# Tag a specific tool with a version +make release-tag TOOL=banner-grabber VERSION=v1.0.0 + +# Push the tag to trigger the release pipeline +git push origin banner-grabber/v1.0.0 +``` + +The release workflow will: + +1. Detect which tool to release from the tag prefix +2. Build cross-platform binaries (linux/darwin/windows × amd64/arm64) +3. Generate SHA-256 checksums +4. Create a GitHub Release with changelog, install instructions, and assets + +### Version history + +Tags follow the convention `/v..`: + +| Tag example | Effect | +|-------------|--------| +| `banner-grabber/v1.0.0` | Releases banner-grabber v1.0.0 | +| `port-knocking-scanner/v0.2.0` | Releases port-knocking-scanner v0.2.0 | + +Each tool's version is fully independent — releasing one tool does **not** affect others. + +## CI / CD + +| Workflow | Trigger | What it does | +|----------|---------|--------------| +| **Go** | Push/PR touching `tools/`, `libs/netutil/`, `go.mod` | golangci-lint → tests (race + coverage) → build | +| **Rust** | Push/PR touching `libs/sectools-common/`, `Cargo.toml` | clippy + fmt → tests → release build | +| **Security** | Push/PR to main + weekly cron | govulncheck, cargo-audit, Trivy, CodeQL | +| **Release** | Tag `/v*` | Cross-compile, checksum, GitHub Release | + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +## License + +[MIT](LICENSE) — see [LICENSE](LICENSE) for details. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..dffcd57 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,32 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +|---------|--------------------| +| latest | :white_check_mark: | + +## Reporting a Vulnerability + +If you discover a security vulnerability in any of the tools in this repository, please report it responsibly. + +**Do NOT open a public GitHub issue.** + +Instead, please email: **security@flaviomilan.com** (or use [GitHub Private Security Advisories](https://github.com/flaviomilan/sectools/security/advisories/new)). + +### What to include + +- Description of the vulnerability +- Steps to reproduce +- Potential impact +- Suggested fix (if any) + +### Response Timeline + +- **Acknowledgment:** within 48 hours +- **Initial assessment:** within 7 days +- **Fix/disclosure:** coordinated with reporter + +## Disclaimer + +The tools in this repository are intended for **authorized security testing and educational purposes only**. Misuse of these tools against systems you do not own or have permission to test is illegal and unethical. The authors assume no liability for misuse. diff --git a/banner-grabber/go.mod b/banner-grabber/go.mod deleted file mode 100644 index 5101dbc..0000000 --- a/banner-grabber/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module banner_grabber - -go 1.24.2 diff --git a/banner-grabber/main.go b/banner-grabber/main.go deleted file mode 100644 index d185625..0000000 --- a/banner-grabber/main.go +++ /dev/null @@ -1,70 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "net" - "os" - "strings" - "time" -) - -func bannerGrab(host string, port string, timeout time.Duration, payload string) string { - address := net.JoinHostPort(host, port) - conn, err := net.DialTimeout("tcp", address, timeout) - if err != nil { - return fmt.Sprintf("❌ Error connecting to %s: %v\n", address, err) - } - defer conn.Close() - - if payload != "" { - _, err := conn.Write([]byte(payload)) - if err != nil { - return fmt.Sprintf("⚠️ Failed to send data to %s: %v\n", address, err) - } - } - - conn.SetReadDeadline(time.Now().Add(timeout)) - buffer := make([]byte, 1024) - n, err := conn.Read(buffer) - if err != nil { - return fmt.Sprintf("⚠️ No banner received from %s (or connection closed): %v\n", address, err) - } - - return fmt.Sprintf("✅ Banner from %s:\n%s\n", address, string(buffer[:n])) -} - -func main() { - host := flag.String("host", "", "Target host (e.g., scanme.nmap.org)") - ports := flag.String("ports", "", "Target port(s), comma-separated (e.g., 22,80,443)") - timeout := flag.Int("timeout", 5, "Connection timeout in seconds") - payload := flag.String("send", "", "Optional payload to send before reading (e.g., GET / HTTP/1.0\\r\\n\\r\\n)") - output := flag.String("output", "", "Optional output file to save banners") - - flag.Parse() - - if *host == "" || *ports == "" { - fmt.Println("❗ Usage: go run main.go -host -ports [options]") - flag.PrintDefaults() - return - } - - portList := strings.Split(*ports, ",") - var results []string - - for _, port := range portList { - port = strings.TrimSpace(port) - result := bannerGrab(*host, port, time.Duration(*timeout)*time.Second, *payload) - fmt.Print(result) - results = append(results, result) - } - - if *output != "" { - err := os.WriteFile(*output, []byte(strings.Join(results, "\n")), 0644) - if err != nil { - fmt.Printf("❌ Error saving to file %s: %v\n", *output, err) - return - } - fmt.Printf("📁 Results saved to: %s\n", *output) - } -} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0b5b74d --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/flaviomilan/sectools + +go 1.24.2 + +require github.com/google/gopacket v1.1.19 + +require golang.org/x/sys v0.41.0 // indirect diff --git a/port-knocking-scanner/go.sum b/go.sum similarity index 90% rename from port-knocking-scanner/go.sum rename to go.sum index a915606..462dd43 100644 --- a/port-knocking-scanner/go.sum +++ b/go.sum @@ -9,8 +9,9 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwL golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/libs/netutil/netutil.go b/libs/netutil/netutil.go new file mode 100644 index 0000000..fee8e2d --- /dev/null +++ b/libs/netutil/netutil.go @@ -0,0 +1,91 @@ +// Package netutil provides common network utility functions +// shared across sectools commands. +package netutil + +import ( + "fmt" + "net" + "os" + "strconv" + "strings" + "time" +) + +// IsValidIP checks whether the given string is a valid IP address. +func IsValidIP(ip string) bool { + return net.ParseIP(ip) != nil +} + +// GetLocalIPv4 returns the first non-loopback IPv4 address of the named interface. +func GetLocalIPv4(interfaceName string) (net.IP, error) { + ifaceObj, err := net.InterfaceByName(interfaceName) + if err != nil { + return nil, fmt.Errorf("interface %s: %w", interfaceName, err) + } + + addrs, err := ifaceObj.Addrs() + if err != nil { + return nil, fmt.Errorf("addresses for %s: %w", interfaceName, err) + } + + for _, addr := range addrs { + if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() { + if ip4 := ipNet.IP.To4(); ip4 != nil { + return ip4, nil + } + } + } + + return nil, fmt.Errorf("no IPv4 address found on interface %s", interfaceName) +} + +// ParsePorts splits a comma-separated port string into a slice of ints. +func ParsePorts(input string) ([]int, error) { + parts := strings.Split(input, ",") + ports := make([]int, 0, len(parts)) + for _, p := range parts { + port, err := strconv.Atoi(strings.TrimSpace(p)) + if err != nil { + return nil, fmt.Errorf("invalid port %q: %w", p, err) + } + if port < 1 || port > 65535 { + return nil, fmt.Errorf("port %d out of range (1-65535)", port) + } + ports = append(ports, port) + } + return ports, nil +} + +// GrabBanner connects to host:port via TCP and reads the first response bytes. +func GrabBanner(host, port string, timeout time.Duration, payload string) (string, error) { + address := net.JoinHostPort(host, port) + conn, err := net.DialTimeout("tcp", address, timeout) + if err != nil { + return "", fmt.Errorf("connecting to %s: %w", address, err) + } + defer conn.Close() + + if payload != "" { + if _, err := conn.Write([]byte(payload)); err != nil { + return "", fmt.Errorf("sending payload to %s: %w", address, err) + } + } + + if err := conn.SetReadDeadline(time.Now().Add(timeout)); err != nil { + return "", fmt.Errorf("setting deadline for %s: %w", address, err) + } + + buffer := make([]byte, 4096) + n, err := conn.Read(buffer) + if err != nil { + return "", fmt.Errorf("reading banner from %s: %w", address, err) + } + + return string(buffer[:n]), nil +} + +// MustExitf prints a formatted error message and exits with code 1. +func MustExitf(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} diff --git a/libs/netutil/netutil_test.go b/libs/netutil/netutil_test.go new file mode 100644 index 0000000..819d2e4 --- /dev/null +++ b/libs/netutil/netutil_test.go @@ -0,0 +1,68 @@ +package netutil + +import ( + "testing" +) + +func TestIsValidIP(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"192.168.1.1", true}, + {"10.0.0.1", true}, + {"255.255.255.255", true}, + {"0.0.0.0", true}, + {"::1", true}, + {"not-an-ip", false}, + {"999.999.999.999", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := IsValidIP(tt.input) + if got != tt.want { + t.Errorf("IsValidIP(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestParsePorts(t *testing.T) { + tests := []struct { + name string + input string + want []int + wantErr bool + }{ + {"single port", "80", []int{80}, false}, + {"multiple ports", "22,80,443", []int{22, 80, 443}, false}, + {"with spaces", "22, 80, 443", []int{22, 80, 443}, false}, + {"invalid port", "abc", nil, true}, + {"out of range", "99999", nil, true}, + {"zero port", "0", nil, true}, + {"negative port", "-1", nil, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParsePorts(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ParsePorts(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + return + } + if !tt.wantErr { + if len(got) != len(tt.want) { + t.Errorf("ParsePorts(%q) = %v, want %v", tt.input, got, tt.want) + return + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("ParsePorts(%q)[%d] = %d, want %d", tt.input, i, got[i], tt.want[i]) + } + } + } + }) + } +} diff --git a/libs/sectools-common/Cargo.toml b/libs/sectools-common/Cargo.toml new file mode 100644 index 0000000..bccb148 --- /dev/null +++ b/libs/sectools-common/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "sectools-common" +description = "Shared utilities for sectools Rust tools" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +[dependencies] diff --git a/libs/sectools-common/src/lib.rs b/libs/sectools-common/src/lib.rs new file mode 100644 index 0000000..8f26d01 --- /dev/null +++ b/libs/sectools-common/src/lib.rs @@ -0,0 +1,83 @@ +//! Shared utilities for sectools Rust tools. + +use std::io::{Read, Write}; +use std::net::{IpAddr, SocketAddr, TcpStream}; +use std::time::Duration; + +/// Check whether the given string is a valid IP address. +pub fn is_valid_ip(ip: &str) -> bool { + ip.parse::().is_ok() +} + +/// Parse a comma-separated port string into a Vec of u16. +pub fn parse_ports(input: &str) -> Result, String> { + input + .split(',') + .map(|s| { + let s = s.trim(); + s.parse::() + .map_err(|_| format!("invalid port: {s}")) + .and_then(|p| { + if p == 0 { + Err("port 0 is not valid".to_string()) + } else { + Ok(p) + } + }) + }) + .collect() +} + +/// Grab a banner from a TCP service. +pub fn grab_banner( + host: &str, + port: u16, + timeout: Duration, + payload: Option<&[u8]>, +) -> Result { + let addr: SocketAddr = format!("{host}:{port}") + .parse() + .map_err(|e| format!("invalid address: {e}"))?; + + let mut stream = TcpStream::connect_timeout(&addr, timeout) + .map_err(|e| format!("connection failed: {e}"))?; + + stream + .set_read_timeout(Some(timeout)) + .map_err(|e| format!("set timeout: {e}"))?; + + if let Some(data) = payload { + stream + .write_all(data) + .map_err(|e| format!("send failed: {e}"))?; + } + + let mut buf = vec![0u8; 4096]; + let n = stream + .read(&mut buf) + .map_err(|e| format!("read failed: {e}"))?; + + Ok(String::from_utf8_lossy(&buf[..n]).to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_valid_ip() { + assert!(is_valid_ip("192.168.1.1")); + assert!(is_valid_ip("::1")); + assert!(!is_valid_ip("not-an-ip")); + assert!(!is_valid_ip("")); + } + + #[test] + fn test_parse_ports() { + assert_eq!(parse_ports("80").unwrap(), vec![80]); + assert_eq!(parse_ports("22,80,443").unwrap(), vec![22, 80, 443]); + assert_eq!(parse_ports("22, 80, 443").unwrap(), vec![22, 80, 443]); + assert!(parse_ports("abc").is_err()); + assert!(parse_ports("0").is_err()); + } +} diff --git a/port-knocking-scanner/go.mod b/port-knocking-scanner/go.mod deleted file mode 100644 index b3e39ce..0000000 --- a/port-knocking-scanner/go.mod +++ /dev/null @@ -1,7 +0,0 @@ -module knocking_scanner - -go 1.24.2 - -require github.com/google/gopacket v1.1.19 - -require golang.org/x/sys v0.0.0-20190412213103-97732733099d // indirect diff --git a/port-knocking-scanner/main.go b/port-knocking-scanner/main.go deleted file mode 100644 index 67440c7..0000000 --- a/port-knocking-scanner/main.go +++ /dev/null @@ -1,168 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "math/rand" - "net" - "os" - "strconv" - "strings" - "sync" - "time" - - "github.com/google/gopacket" - "github.com/google/gopacket/layers" - "github.com/google/gopacket/pcap" -) - -func isValidIP(ip string) bool { - return net.ParseIP(ip) != nil -} - -func getLocalIP(interfaceName string) net.IP { - ifaceObj, err := net.InterfaceByName(interfaceName) - if err != nil { - fmt.Printf("[-] Erro ao obter interface %s: %v\n", interfaceName, err) - os.Exit(1) - } - - addrs, err := ifaceObj.Addrs() - if err != nil { - fmt.Printf("[-] Erro ao obter enderecos da interface: %v\n", err) - os.Exit(1) - } - - for _, addr := range addrs { - if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() { - if ip4 := ipNet.IP.To4(); ip4 != nil { - return ip4 - } - } - } - - fmt.Println("[-] Nao foi possivel determinar o IP da interface.") - os.Exit(1) - return nil -} - -func sendSYN(ip string, port int, handle *pcap.Handle, srcPort layers.TCPPort, localIP net.IP) { - ipLayer := layers.IPv4{ - SrcIP: localIP, - DstIP: net.ParseIP(ip), - Protocol: layers.IPProtocolTCP, - } - tcp := layers.TCP{ - SrcPort: srcPort, - DstPort: layers.TCPPort(port), - SYN: true, - Seq: rand.Uint32(), - Window: 14600, - } - tcp.SetNetworkLayerForChecksum(&ipLayer) - - buf := gopacket.NewSerializeBuffer() - opts := gopacket.SerializeOptions{ - ComputeChecksums: true, - FixLengths: true, - } - gopacket.SerializeLayers(buf, opts, &ipLayer, &tcp) - handle.WritePacketData(buf.Bytes()) -} - -func knockAndCheck(wg *sync.WaitGroup, ip string, ports []int, resultChan chan<- string, iface string, localIP net.IP) { - defer wg.Done() - - handle, err := pcap.OpenLive(iface, 65536, false, pcap.BlockForever) - if err != nil { - fmt.Printf("Erro ao abrir interface: %v\n", err) - return - } - defer handle.Close() - - srcPort := layers.TCPPort(rand.Intn(65535-1024) + 1024) - for _, port := range ports[:len(ports)-1] { - sendSYN(ip, port, handle, srcPort, localIP) - time.Sleep(100 * time.Millisecond) - } - - lastPort := ports[len(ports)-1] - conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", ip, lastPort), 1*time.Second) - if err == nil { - resultChan <- ip - conn.Close() - } else { - fmt.Print(".") - } -} - -func main() { - startIP := flag.String("start", "", "IP inicial (ex: 192.168.0.1)") - endIP := flag.String("end", "", "IP final (ex: 192.168.0.254)") - portsInput := flag.String("ports", "13,37,30000,3000,1337", "Portas para knocking separadas por vírgula") - iface := flag.String("iface", "eth0", "Interface de rede para envio dos pacotes") - flag.Parse() - - if *startIP == "" || *endIP == "" { - fmt.Println("[-] Uso: ./scanner -start -end [-ports ] -iface ") - return - } - - if !isValidIP(*startIP) || !isValidIP(*endIP) { - fmt.Println("[-] Enderecos IP invalidos!") - return - } - - rede1 := strings.Join(strings.Split(*startIP, ".")[:3], ".") - rede2 := strings.Join(strings.Split(*endIP, ".")[:3], ".") - - if rede1 != rede2 { - fmt.Println("[-] Os IPs devem estar na mesma rede /24") - return - } - - inicio, _ := strconv.Atoi(strings.Split(*startIP, ".")[3]) - fim, _ := strconv.Atoi(strings.Split(*endIP, ".")[3]) - - portsStr := strings.Split(*portsInput, ",") - ports := []int{} - for _, p := range portsStr { - port, err := strconv.Atoi(strings.TrimSpace(p)) - if err != nil { - fmt.Printf("[-] Porta invalida: %s\n", p) - return - } - ports = append(ports, port) - } - - localIP := getLocalIP(*iface) - - var wg sync.WaitGroup - resultChan := make(chan string, 256) - - fmt.Printf("[+] Testando hosts de %s a %s nas portas %v...\n", *startIP, *endIP, ports) - - for i := inicio; i <= fim; i++ { - ip := fmt.Sprintf("%s.%d", rede1, i) - wg.Add(1) - go knockAndCheck(&wg, ip, ports, resultChan, *iface, localIP) - } - - wg.Wait() - close(resultChan) - - found := []string{} - for ip := range resultChan { - found = append(found, ip) - } - - fmt.Println() - if len(found) > 0 { - fmt.Printf("[+] Port knocking encontrado em %d hosts!\n", len(found)) - for _, ip := range found { - fmt.Printf("[+] Host identificado %s...\n", ip) - } - } else { - fmt.Println("[-] Nenhum host comprometido encontrado.") - } -} diff --git a/tools/banner-grabber/main.go b/tools/banner-grabber/main.go new file mode 100644 index 0000000..d9a3764 --- /dev/null +++ b/tools/banner-grabber/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "flag" + "fmt" + "net" + "os" + "strings" + "time" + + "github.com/flaviomilan/sectools/libs/netutil" +) + +// version is set at build time via -ldflags. +var version = "dev" + +func main() { + host := flag.String("host", "", "Target host (e.g., scanme.nmap.org)") + ports := flag.String("ports", "", "Target port(s), comma-separated (e.g., 22,80,443)") + timeout := flag.Int("timeout", 5, "Connection timeout in seconds") + payload := flag.String("send", "", "Optional payload to send before reading (e.g., GET / HTTP/1.0\\r\\n\\r\\n)") + output := flag.String("output", "", "Optional output file to save banners") + showVersion := flag.Bool("version", false, "Print version and exit") + + flag.Parse() + + if *showVersion { + fmt.Printf("banner-grabber %s\n", version) + return + } + + if *host == "" || *ports == "" { + fmt.Fprintln(os.Stderr, "Usage: banner-grabber -host -ports [options]") + flag.PrintDefaults() + os.Exit(1) + } + + portList := strings.Split(*ports, ",") + dur := time.Duration(*timeout) * time.Second + results := make([]string, 0, len(portList)) + + for _, port := range portList { + port = strings.TrimSpace(port) + address := net.JoinHostPort(*host, port) + + banner, err := netutil.GrabBanner(*host, port, dur, *payload) + if err != nil { + msg := fmt.Sprintf("[!] %s: %v", address, err) + fmt.Fprintln(os.Stderr, msg) + results = append(results, msg) + continue + } + + msg := fmt.Sprintf("[+] %s\n%s", address, banner) + fmt.Print(msg) + results = append(results, msg) + } + + if *output != "" { + err := os.WriteFile(*output, []byte(strings.Join(results, "\n")), 0600) + if err != nil { + netutil.MustExitf("[!] Error saving to %s: %v", *output, err) + } + fmt.Fprintf(os.Stderr, "[*] Results saved to: %s\n", *output) + } +} diff --git a/tools/port-knocking-scanner/main.go b/tools/port-knocking-scanner/main.go new file mode 100644 index 0000000..a7f4ded --- /dev/null +++ b/tools/port-knocking-scanner/main.go @@ -0,0 +1,170 @@ +package main + +import ( + "crypto/rand" + "encoding/binary" + "flag" + "fmt" + "net" + "strconv" + "strings" + "sync" + "time" + + "github.com/flaviomilan/sectools/libs/netutil" + "github.com/google/gopacket" + "github.com/google/gopacket/layers" + "github.com/google/gopacket/pcap" +) + +// version is set at build time via -ldflags. +var version = "dev" + +func cryptoRandUint32() uint32 { + var b [4]byte + _, _ = rand.Read(b[:]) + return binary.LittleEndian.Uint32(b[:]) +} + +func sendSYN(ip string, port int, handle *pcap.Handle, srcPort layers.TCPPort, localIP net.IP) error { + ipLayer := layers.IPv4{ + SrcIP: localIP, + DstIP: net.ParseIP(ip), + Protocol: layers.IPProtocolTCP, + } + tcp := layers.TCP{ + SrcPort: srcPort, + DstPort: layers.TCPPort(uint16(port)), //nolint:gosec // port already validated in range 0-65535 + SYN: true, + Seq: cryptoRandUint32(), + Window: 14600, + } + if err := tcp.SetNetworkLayerForChecksum(&ipLayer); err != nil { + return fmt.Errorf("set checksum: %w", err) + } + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{ + ComputeChecksums: true, + FixLengths: true, + } + if err := gopacket.SerializeLayers(buf, opts, &ipLayer, &tcp); err != nil { + return fmt.Errorf("serialize: %w", err) + } + if err := handle.WritePacketData(buf.Bytes()); err != nil { + return fmt.Errorf("write packet: %w", err) + } + return nil +} + +func knockAndCheck(wg *sync.WaitGroup, ip string, ports []int, resultChan chan<- string, iface string, localIP net.IP) { + defer wg.Done() + + handle, err := pcap.OpenLive(iface, 65536, false, pcap.BlockForever) + if err != nil { + fmt.Printf("[!] Error opening interface: %v\n", err) + return + } + defer handle.Close() + + srcPort := layers.TCPPort(1024 + uint16(cryptoRandUint32()%(65535-1024))) //nolint:gosec // overflow impossible + for _, port := range ports[:len(ports)-1] { + if err := sendSYN(ip, port, handle, srcPort, localIP); err != nil { + fmt.Printf("[!] Error sending SYN to %s:%d: %v\n", ip, port, err) + return + } + time.Sleep(100 * time.Millisecond) + } + + lastPort := ports[len(ports)-1] + conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", ip, lastPort), 1*time.Second) + if err == nil { + resultChan <- ip + conn.Close() + } else { + fmt.Print(".") + } +} + +func main() { + startIP := flag.String("start", "", "Start IP (e.g., 192.168.0.1)") + endIP := flag.String("end", "", "End IP (e.g., 192.168.0.254)") + portsInput := flag.String("ports", "13,37,30000,3000,1337", "Knock ports, comma-separated") + iface := flag.String("iface", "eth0", "Network interface for packet injection") + showVersion := flag.Bool("version", false, "Print version and exit") + flag.Parse() + + if *showVersion { + fmt.Printf("port-knocking-scanner %s\n", version) + return + } + + if *startIP == "" || *endIP == "" { + netutil.MustExitf("Usage: port-knocking-scanner -start -end [-ports ] -iface ") + } + + if !netutil.IsValidIP(*startIP) || !netutil.IsValidIP(*endIP) { + netutil.MustExitf("[-] Invalid IP addresses") + } + + net1 := strings.Join(strings.Split(*startIP, ".")[:3], ".") + net2 := strings.Join(strings.Split(*endIP, ".")[:3], ".") + + if net1 != net2 { + netutil.MustExitf("[-] IPs must be in the same /24 network") + } + + ports, err := netutil.ParsePorts(*portsInput) + if err != nil { + netutil.MustExitf("[-] %v", err) + } + + localIP, err := netutil.GetLocalIPv4(*iface) + if err != nil { + netutil.MustExitf("[-] %v", err) + } + + start := netIPLastOctet(*startIP) + end := netIPLastOctet(*endIP) + + var wg sync.WaitGroup + resultChan := make(chan string, 256) + + fmt.Printf("[+] Scanning %s to %s on ports %v...\n", *startIP, *endIP, ports) + + for i := start; i <= end; i++ { + ip := fmt.Sprintf("%s.%d", net1, i) + wg.Add(1) + go knockAndCheck(&wg, ip, ports, resultChan, *iface, localIP) + } + + wg.Wait() + close(resultChan) + + found := make([]string, 0, len(resultChan)) + for ip := range resultChan { + found = append(found, ip) + } + + fmt.Println() + if len(found) > 0 { + fmt.Printf("[+] Port knocking detected on %d host(s)!\n", len(found)) + for _, ip := range found { + fmt.Printf("[+] Host: %s\n", ip) + } + } else { + fmt.Println("[-] No hosts found.") + } +} + +func netIPLastOctet(ip string) int { + parts := strings.Split(ip, ".") + if len(parts) != 4 { + return 0 + } + n, err := strconv.Atoi(parts[3]) + if err != nil { + return 0 + } + return n +}