Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
45a0eed
feat: add --include-files flag for targeted file scanning in PR workf…
yiftach-armis Jan 18, 2026
d25446e
fix: use cross-platform absolute paths in test
yiftach-armis Jan 18, 2026
cfec25b
fix: use local action for testing and prevent command injection
yiftach-armis Jan 18, 2026
c750399
fix: build CLI from source for PR testing
yiftach-armis Jan 18, 2026
9f9b9a6
Merge branch 'main' into feature/scan-changed-files
yiftach-armis Jan 18, 2026
61856a6
feat: enhance SARIF output with detailed findings and improve PR comm…
yiftach-armis Jan 18, 2026
e207264
fix: add nolint directive for errcheck on pipe close
yiftach-armis Jan 18, 2026
a7b2b53
fix: close file immediately in tarGzFiles loop to prevent resource leak
yiftach-armis Jan 18, 2026
53d95e6
fix: address security scan findings
yiftach-armis Jan 18, 2026
788920e
fix: address additional security scan findings
yiftach-armis Jan 18, 2026
6041916
fix: address security scan findings for CI compliance
yiftach-armis Jan 18, 2026
6e10fb2
fix: close pipe writer on context cancellation to prevent test timeout
yiftach-armis Jan 18, 2026
62bcd93
fix: add nolint directive for errcheck on pipe close
yiftach-armis Jan 18, 2026
f1b4714
fix: add gosec to nolint directive for pipe close
yiftach-armis Jan 18, 2026
0d545f6
fix: add required permissions for reusable security scan workflow
yiftach-armis Jan 18, 2026
b5cc0eb
fix: address remaining security scan findings
yiftach-armis Jan 18, 2026
d557b65
fix: use released binary by default for security scans
yiftach-armis Jan 18, 2026
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
49 changes: 49 additions & 0 deletions .github/workflows/pr-security-scan.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: PR Security Scan

on:
pull_request:
branches: [main]

permissions:
contents: read
security-events: write
actions: read
pull-requests: write

jobs:
get-changed-files:
name: Get Changed Files
runs-on: ubuntu-latest
outputs:
files: ${{ steps.changed-files.outputs.all_changed_files }}
any_changed: ${{ steps.changed-files.outputs.any_changed }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v46
with:
separator: ','
# Exclude test files from security scan - they contain intentional
# path traversal test data that triggers false positives
files_ignore: |
**/*_test.go
**/testdata/**

security-scan:
name: Security Scan
needs: get-changed-files
if: needs.get-changed-files.outputs.any_changed == 'true'
uses: ./.github/workflows/reusable-security-scan.yml
with:
scan-type: repo
fail-on: 'CRITICAL,HIGH'
include-files: ${{ needs.get-changed-files.outputs.files }}
build-from-source: false
secrets:
api-token: ${{ secrets.ARMIS_API_TOKEN }}
tenant-id: ${{ secrets.ARMIS_TENANT_ID }}
91 changes: 89 additions & 2 deletions .github/workflows/reusable-security-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ on:
description: 'Scan timeout in minutes'
type: number
default: 60
include-files:
description: 'Comma-separated list of file paths to scan (relative to repository root)'
type: string
default: ''
build-from-source:
description: 'Build CLI from source instead of downloading release (for testing scanner changes)'
type: boolean
default: false
secrets:
api-token:
description: 'Armis API token for authentication'
Expand All @@ -43,6 +51,14 @@ on:
description: 'Tenant identifier for Armis Cloud'
required: true

# Top-level permissions define the maximum permissions available to this workflow.
# Job-level permissions further restrict as needed.
permissions:
contents: read
security-events: write
actions: read
pull-requests: write

jobs:
security-scan:
name: Armis Security Scan
Expand All @@ -62,7 +78,7 @@ jobs:

- name: Run Armis Security Scan
id: armis_scan
uses: ArmisSecurity/armis-cli@main
uses: ./
with:
scan-type: ${{ inputs.scan-type }}
scan-target: ${{ inputs.scan-target }}
Expand All @@ -73,6 +89,8 @@ jobs:
output-file: armis-results.sarif
image-tarball: ${{ inputs.image-tarball }}
scan-timeout: ${{ inputs.scan-timeout }}
include-files: ${{ inputs.include-files }}
build-from-source: ${{ inputs.build-from-source }}
continue-on-error: true

- name: Ensure SARIF exists
Expand Down Expand Up @@ -121,7 +139,76 @@ jobs:
if (counts.LOW > 0) body += `| 🔵 LOW | ${counts.LOW} |\n`;
if (counts.INFO > 0) body += `| ⚪ INFO | ${counts.INFO} |\n`;
body += `\n**Total: ${total}**\n`;
body += `\n<details><summary>View full results</summary>\n\nSee the Security tab or download the \`armis-security-results\` artifact for the complete SARIF report.\n</details>`;

// Build detailed findings section
if (total > 0) {
body += `\n<details><summary>View all ${total} findings</summary>\n\n`;

// Group results by severity
const severityOrder = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'];
const severityEmoji = { CRITICAL: '🔴', HIGH: '🟠', MEDIUM: '🟡', LOW: '🔵', INFO: '⚪' };

for (const severity of severityOrder) {
const severityResults = results.filter(r =>
(r.properties?.severity || 'INFO') === severity
);

if (severityResults.length > 0) {
body += `### ${severityEmoji[severity]} ${severity} (${severityResults.length})\n\n`;

for (const r of severityResults) {
const file = r.locations?.[0]?.physicalLocation?.artifactLocation?.uri || '';
const line = r.locations?.[0]?.physicalLocation?.region?.startLine || '';
const location = file ? (line ? `${file}:${line}` : file) : 'Unknown location';

// Parse title and description from message
const msgParts = (r.message?.text || '').split(': ');
const title = msgParts[0] || r.ruleId;
const description = msgParts.slice(1).join(': ') || '';

body += `<details><summary><code>${r.ruleId}</code> - ${title}</summary>\n\n`;
body += `**Location:** \`${location}\`\n\n`;

if (description) {
body += `${description}\n\n`;
}

// Code snippet
const snippet = r.properties?.codeSnippet;
if (snippet) {
body += '```\n' + snippet + '\n```\n\n';
}

// CVEs and CWEs
const cves = r.properties?.cves || [];
const cwes = r.properties?.cwes || [];
if (cves.length > 0) {
body += `**CVEs:** ${cves.join(', ')}\n\n`;
}
if (cwes.length > 0) {
body += `**CWEs:** ${cwes.join(', ')}\n\n`;
}

// Package info
const pkg = r.properties?.package;
const version = r.properties?.version;
const fixVersion = r.properties?.fixVersion;
if (pkg) {
let pkgInfo = `**Package:** ${pkg}`;
if (version) pkgInfo += ` (${version})`;
if (fixVersion) pkgInfo += ` → Fix: ${fixVersion}`;
body += pkgInfo + '\n\n';
}

body += `</details>\n\n`;
}
}
}

body += `</details>`;
} else {
body += `\n<details><summary>View full results</summary>\n\nNo security issues found.\n</details>`;
}

// Find and update existing comment, or create new
const { data: comments } = await github.rest.issues.listComments({
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/security-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ jobs:
pr-comment: false # No PR context for scheduled runs
upload-artifact: true
scan-timeout: 120 # 2 hours
build-from-source: false
secrets:
api-token: ${{ secrets.ARMIS_API_TOKEN }}
tenant-id: ${{ secrets.ARMIS_TENANT_ID }}
86 changes: 84 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ inputs:
description: 'Scan timeout in minutes'
required: false
default: '60'
include-files:
description: 'Comma-separated list of file paths to scan (relative to repository root)'
required: false
default: ''
build-from-source:
description: 'Build CLI from source instead of downloading release (for testing)'
required: false
default: 'false'

outputs:
results:
Expand All @@ -58,11 +66,80 @@ outputs:
runs:
using: 'composite'
steps:
- name: Install Armis CLI
- name: Setup Go
if: inputs.build-from-source == 'true'
uses: actions/setup-go@v5
with:
go-version: '1.23'

- name: Build Armis CLI from source
if: inputs.build-from-source == 'true'
shell: bash
run: |
echo "Building Armis CLI from source..."
go build -o armis-cli ./cmd/armis-cli
mkdir -p "$HOME/.local/bin"
mv armis-cli "$HOME/.local/bin/"
echo "$HOME/.local/bin" >> $GITHUB_PATH

- name: Install Armis CLI from release
if: inputs.build-from-source != 'true'
shell: bash
run: |
echo "Installing Armis CLI..."
curl -sSL https://raw.githubusercontent.com/ArmisSecurity/armis-cli/main/scripts/install.sh | bash

# Detect OS and architecture
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
case "$OS" in
linux*) OS="linux" ;;
darwin*) OS="darwin" ;;
*) echo "Error: Unsupported OS: $OS"; exit 1 ;;
esac

ARCH=$(uname -m)
case "$ARCH" in
x86_64|amd64) ARCH="amd64" ;;
aarch64|arm64) ARCH="arm64" ;;
*) echo "Error: Unsupported architecture: $ARCH"; exit 1 ;;
esac

# Download binary directly from GitHub releases
REPO="ArmisSecurity/armis-cli"
ARCHIVE_NAME="armis-cli-${OS}-${ARCH}.tar.gz"
CHECKSUMS_NAME="armis-cli-checksums.txt"
BASE_URL="https://github.com/${REPO}/releases/latest/download"

TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT

echo "Downloading $ARCHIVE_NAME..."
curl -fsSL "${BASE_URL}/${ARCHIVE_NAME}" -o "${TMP_DIR}/${ARCHIVE_NAME}"
curl -fsSL "${BASE_URL}/${CHECKSUMS_NAME}" -o "${TMP_DIR}/${CHECKSUMS_NAME}"

# Verify checksum
echo "Verifying checksum..."
cd "$TMP_DIR"
EXPECTED=$(grep "$ARCHIVE_NAME" "$CHECKSUMS_NAME" | awk '{print $1}')
if command -v sha256sum > /dev/null 2>&1; then
ACTUAL=$(sha256sum "$ARCHIVE_NAME" | awk '{print $1}')
else
ACTUAL=$(shasum -a 256 "$ARCHIVE_NAME" | awk '{print $1}')
fi

if [ "$EXPECTED" != "$ACTUAL" ]; then
echo "Error: Checksum verification failed!"
echo "Expected: $EXPECTED"
echo "Actual: $ACTUAL"
exit 1
fi
echo "Checksum verified successfully"

# Extract and install
tar -xzf "$ARCHIVE_NAME"
mkdir -p "$HOME/.local/bin"
mv armis-cli "$HOME/.local/bin/"
chmod +x "$HOME/.local/bin/armis-cli"
echo "Armis CLI installed successfully"
echo "$HOME/.local/bin" >> $GITHUB_PATH

- name: Verify Installation
Expand All @@ -85,6 +162,7 @@ runs:
NO_PROGRESS: ${{ inputs.no-progress }}
IMAGE_TARBALL: ${{ inputs.image-tarball }}
OUTPUT_FILE: ${{ inputs.output-file }}
INCLUDE_FILES: ${{ inputs.include-files }}
run: |
set +e

Expand Down Expand Up @@ -114,6 +192,10 @@ runs:
SCAN_ARGS+=("--tarball" "$IMAGE_TARBALL")
fi

if [ -n "$INCLUDE_FILES" ]; then
SCAN_ARGS+=("--include-files" "$INCLUDE_FILES")
fi

# Execute command safely without eval
if [ -n "$OUTPUT_FILE" ]; then
armis-cli "${SCAN_ARGS[@]}" > "$OUTPUT_FILE"
Expand Down
2 changes: 2 additions & 0 deletions internal/cmd/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ var (
uploadTimeout int
includeNonExploitable bool
groupBy string
includeFiles []string
)

var scanCmd = &cobra.Command{
Expand All @@ -24,6 +25,7 @@ func init() {
scanCmd.PersistentFlags().IntVar(&uploadTimeout, "upload-timeout", 10, "Maximum time in minutes to wait for artifact upload to complete")
scanCmd.PersistentFlags().BoolVar(&includeNonExploitable, "include-non-exploitable", false, "Include findings marked as non-exploitable (only exploitable findings shown by default)")
scanCmd.PersistentFlags().StringVar(&groupBy, "group-by", "none", "Group findings by: none, cwe, severity, file")
scanCmd.PersistentFlags().StringSliceVar(&includeFiles, "include-files", nil, "Comma-separated list of file paths to include in scan (relative to repository root)")
if rootCmd != nil {
rootCmd.AddCommand(scanCmd)
}
Expand Down
17 changes: 17 additions & 0 deletions internal/cmd/scan_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"fmt"
"os"
"path/filepath"
"time"

"github.com/ArmisSecurity/armis-cli/internal/api"
Expand Down Expand Up @@ -47,6 +48,22 @@ var scanRepoCmd = &cobra.Command{
scanTimeoutDuration := time.Duration(scanTimeout) * time.Minute
scanner := repo.NewScanner(client, noProgress, tid, limit, includeTests, scanTimeoutDuration, includeNonExploitable)

// Handle --include-files flag for targeted file scanning
// Security: Path traversal protection is enforced by ParseFileList which
// validates all paths using SafeJoinPath to ensure they don't escape the
// repository root. Invalid or traversal paths are rejected with an error.
if len(includeFiles) > 0 {
absPath, err := filepath.Abs(repoPath)
if err != nil {
return fmt.Errorf("failed to resolve path: %w", err)
}
fileList, err := repo.ParseFileList(absPath, includeFiles)
if err != nil {
return fmt.Errorf("invalid --include-files: %w", err)
}
scanner = scanner.WithIncludeFiles(fileList)
}

ctx, cancel := NewSignalContext()
defer cancel()

Expand Down
29 changes: 26 additions & 3 deletions internal/output/sarif.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,14 @@ type sarifResult struct {
}

type sarifResultProperties struct {
Severity string `json:"severity"`
Severity string `json:"severity"`
Type string `json:"type,omitempty"`
CodeSnippet string `json:"codeSnippet,omitempty"`
CVEs []string `json:"cves,omitempty"`
CWEs []string `json:"cwes,omitempty"`
Package string `json:"package,omitempty"`
Version string `json:"version,omitempty"`
FixVersion string `json:"fixVersion,omitempty"`
}

type sarifMessage struct {
Expand Down Expand Up @@ -138,8 +145,17 @@ func buildRules(findings []model.Finding) ([]sarifRule, map[string]int) {
return rules, ruleIndexMap
}

// maxSarifResultsCapacity is the maximum initial capacity for SARIF results slice
// to prevent resource exhaustion from extremely large finding lists (CWE-770).
const maxSarifResultsCapacity = 10000

func convertToSarifResults(findings []model.Finding, ruleIndexMap map[string]int) []sarifResult {
results := make([]sarifResult, 0, len(findings))
// Cap the initial capacity to prevent excessive memory allocation (CWE-770)
capacity := len(findings)
if capacity > maxSarifResultsCapacity {
capacity = maxSarifResultsCapacity
}
results := make([]sarifResult, 0, capacity)

for _, finding := range findings {
result := sarifResult{
Expand All @@ -150,7 +166,14 @@ func convertToSarifResults(findings []model.Finding, ruleIndexMap map[string]int
Text: finding.Title + ": " + finding.Description,
},
Properties: &sarifResultProperties{
Severity: string(finding.Severity),
Severity: string(finding.Severity),
Type: string(finding.Type),
CodeSnippet: util.MaskSecretInLine(finding.CodeSnippet), // Defense-in-depth: always sanitize
CVEs: finding.CVEs,
CWEs: finding.CWEs,
Package: finding.Package,
Version: finding.Version,
FixVersion: finding.FixVersion,
},
}

Expand Down
Loading
Loading