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
87 changes: 87 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
name: Build and Verify

on:
push:
branches: [ main, master, develop, "v*" ]
pull_request:
branches: [ main, master, develop ]

jobs:
build:
name: Build & Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4

- name: Build plugin
run: ./gradlew buildPlugin --no-daemon

- name: Run unit tests
run: ./gradlew test --no-daemon

- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: build/reports/tests/

verify:
name: Plugin Verifier
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4

- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4

- name: Run IntelliJ Plugin Verifier
run: ./gradlew runPluginVerifier --no-daemon

- name: Upload verifier report
if: always()
uses: actions/upload-artifact@v4
with:
name: plugin-verifier-report
path: build/reports/pluginVerifier/

publish:
name: Publish to JetBrains Marketplace
runs-on: ubuntu-latest
needs: [ build, verify ]
if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads/main')
steps:
- uses: actions/checkout@v4

- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4

- name: Publish plugin
env:
PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN }}
CERTIFICATE_CHAIN: ${{ secrets.CERTIFICATE_CHAIN }}
PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }}
PRIVATE_KEY_PASSWORD: ${{ secrets.PRIVATE_KEY_PASSWORD }}
run: ./gradlew publishPlugin --no-daemon
70 changes: 70 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,76 @@

## [Unreleased]

## [0.0.35]

### 🛡️ 17 New Security Checks

- **Remote script inclusion** (`apply from: URL`): Detects `apply from: 'https://...'` which downloads and executes arbitrary Groovy/Kotlin at build-configuration time — before any task runs. Whitelistable per-team via `allowed_script_sources` in `.safegradle.yml`.
- **Dependency downgrade via `resolutionStrategy.force`**: Flags `force('group:artifact:version')` calls that silently pin a transitive dependency to a vulnerable version. Automatically cross-referenced against the CVE database.
- **Dangerous JVM daemon flags**: Detects `-javaagent:`, `-agentlib:`, `-agentpath:`, `-Xbootclasspath`, and `--add-opens` in `org.gradle.jvmargs` inside `gradle.properties` — flags that modify the Gradle daemon process itself.
- **Maven/Ivy version range syntax**: Detects open ranges like `(1.0,2.0)` or `[1.0,)` that resolve to a different artifact on every build, enabling silent version substitution attacks.
- **Hardcoded IP-address URLs**: Flags `http(s)://x.x.x.x/` URLs that bypass all domain-based whitelisting. Public IPs only — private ranges (RFC 1918, loopback, link-local) are excluded.
- **Gradle Enterprise / Develocity access keys**: Detects hardcoded `gradle.enterprise.accessKey` and `develocity.accessKey` values that grant access to build scan data and remote build caches.
- **Crypto-mining pool and C2 indicators**: Detects Stratum protocol strings, known mining pool hostnames, and DNS out-of-band exfiltration services (dnslog.cn, ceye.io, etc.).
- **Git hook tampering**: Flags references to `.git/hooks/` paths — writing to this directory installs persistent code that survives the build and executes on every future git operation.
- **`libs.versions.toml` CVE scanning**: Version catalog files are now parsed natively (inline and table-style `{ module = "…", version = "…" }` entries), feeding both the offline CVE database and the live OSV.dev lookup.
- **Android signing credential detection**: Detects hardcoded `storePassword`, `keyPassword`, and `keyAlias` values in `signingConfigs` blocks and `.keystore`/`.jks`/`.p12` file references.
- **JCenter deprecation warning**: `jcenter()` references now emit a `LOW` advisory — JCenter was shut down in February 2022. Migrate to Maven Central.
- **Insecure HTTP repository URLs**: Plain `http://` (non-HTTPS) repository URLs are flagged `HIGH` as active MITM attack vectors.
- **Weak cryptography**: Detects DES, RC4, MD5 (in security context), SHA-1, and RSA keys under 2048 bits with clear remediation guidance.
- **String interpolation in `commandLine`**: Detects `commandLine(..."${var}"...)` patterns where project-property interpolation can be weaponised as a command injection vector.
- **Dependency lock file absence**: Warns (LOW) when no `dependencyLocking {}` configuration or `gradle/dependency-locks/` directory exists, leaving transitive resolution non-deterministic.
- **`.gitignore` exposure audit**: Checks that `.jks`, `*.keystore`, `keystore.properties`, `google-services.json`, `.env`, and other credential-bearing files are excluded from version control.
- **`buildSrc` and composite build scanning**: `buildSrc/` and any `includeBuild()` directories are now fully scanned — they contain arbitrary Kotlin/Groovy code that runs before the main build with unrestricted access.

### ⚡ Performance & Architecture

- **Parallel file scanning**: Files no longer scanned sequentially — a bounded thread pool (sized to CPU core count) scans uncached files concurrently. Large monorepos see significant speed improvements.
- **Scan on save**: Build files are automatically re-scanned when saved. A `BulkFileListener` watches for `VFileContentChangeEvent` on all Gradle-related files and invalidates the cache entry before triggering a background rescan.
- **OSV.dev live CVE integration**: Dependencies are now batched and sent to `api.osv.dev/v1/querybatch` for real-time advisory lookups. The static CVE list remains as an instant offline fallback. Toggle in **Settings → SafeGradle Security**.

### 🎯 New Quick-Fix Intentions

- **Replace `http://` → `https://`** (Alt+Enter): One-click fix for insecure repository URLs on the current line.
- **Pin dynamic version** (Alt+Enter): Replaces `+`, `latest.release`, or `-SNAPSHOT` with a `TODO_PIN_VERSION` placeholder that triggers a compiler warning until pinned.

### 🎨 Tool Window Enhancements

- **Filter bar**: Search field and `🔴 HIGH` / `🟠 MED` / `🔵 LOW` toggle buttons for instant result filtering without leaving the panel.
- **"Explain this violation" detail panel**: Click any row to see the full message, affected code, and file location in a split panel below the table.
- **Violation grouping**: Toggle between grouping by file (default) and grouping by check type to triage all findings of the same category at once.
- **Scan history trend line**: Header now shows the last 5 scan totals as `🔴12 → 🔴8 → 🟠5 → 🔵3` so you can see whether your project is getting more or less secure over time.
- **Baseline mode**: "Save Baseline" saves the current findings as `.safegradle-baseline.json`. Enable "New Only" to show only findings that appeared after the baseline was created — ideal for adopting SafeGradle on existing codebases.
- **Export format chooser**: The export dialog now offers **CSV**, **JSON**, and **SARIF** (GitHub Code Scanning compatible) formats.

### 📊 Status Bar Widget

A persistent `SafeGradle 🔴3 🟠1` counter in the IDE status bar stays visible even when the tool window is closed. Click it to open the SafeGradle panel.

### 👥 Team & CI/CD Features

- **Per-check severity overrides**: Teams can promote or mute any check in `.safegradle.yml`:
```yaml
severity_overrides:
plugin_injection: none # mute entirely
shell_execution: HIGH # promote to HIGH
```
- **`allowed_script_sources`**: Whitelist specific URL prefixes for `apply from:` statements:
```yaml
allowed_script_sources:
- https://raw.githubusercontent.com/myorg/
```
- **Generate GitHub Actions CI workflow**: *Tools → Generate SafeGradle CI Workflow* creates `.github/workflows/safegradle.yml` with a build → test → Plugin Verifier → SARIF upload pipeline.
- **SARIF export for GitHub Code Scanning**: Export findings in SARIF 2.1.0 format and upload to GitHub Code Scanning — findings appear as inline annotations on pull requests.
- **`.safegradle.yml` IDE autocomplete**: The config file now has a registered JSON Schema, providing autocomplete for check IDs, risk levels, and suppression fields in IntelliJ IDEA.

### 🔧 IntelliJ Platform Compatibility

- **Replaced `@UnstableApiUsage` `isTrusted()`** with the stable `TrustedProjects.isProjectTrusted()` API (stable since IntelliJ 2023.1).
- **Replaced `SwingUtilities.invokeLater`** with `ApplicationManager.getApplication().invokeLater()` throughout for correct IntelliJ threading model compliance.
- **Removed dead JCenter/Bintray domains** from the built-in whitelist — both services were shut down in 2022.
- Minimum supported version remains **IntelliJ IDEA 2025.1** (build 251).

## [0.0.32]
### 🛡️ Smarter Detection
- **Catch hidden threats**: SafeGradle now understands the actual structure of your Kotlin and Groovy code, so obfuscated or split-up malicious URLs can no longer hide in string templates.
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ pluginGroup = com.github.safegradle
pluginName = SafeGradle
pluginRepositoryUrl = https://github.com/MohammedAlaaMorsi/SafeGradle
# SemVer format -> https://semver.org
pluginVersion = 0.0.34
pluginVersion = 0.0.35
# Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
pluginSinceBuild = 251

Expand Down
47 changes: 47 additions & 0 deletions src/main/kotlin/com/mohammedalaamorsi/safegradle/ApplyFromCheck.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.mohammedalaamorsi.safegradle

import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFile
import java.util.regex.Pattern

class ApplyFromCheck : SecurityCheck {
override val id = "apply_from_remote"
override val name = "Remote Script Inclusion"
override val description = "Detects apply from: with a remote URL. Remote scripts execute arbitrary Groovy/Kotlin at build-configuration time, before any task runs."

// Matches: apply from: 'https://...', apply from: "http://...", apply(from = "https://...")
private val applyFromPattern = Pattern.compile(
"""apply\s*(?:from\s*[=:]\s*|[\(\{]\s*from\s*[=:]\s*)['"](https?://[^'"]+)['"]""",
Pattern.CASE_INSENSITIVE
)

override fun check(file: VirtualFile, content: String, project: Project?, teamConfig: YamlConfig?): List<SecurityViolation> {
val violations = mutableListOf<SecurityViolation>()
val lines = content.lines()
val allowedSources = teamConfig?.allowedScriptSources ?: emptyList()

lines.forEachIndexed { index, line ->
val stripped = line.trim()
if (stripped.startsWith("//") || stripped.startsWith("#")) return@forEachIndexed

val matcher = applyFromPattern.matcher(line)
while (matcher.find()) {
val url = matcher.group(1)
val isAllowed = allowedSources.any { url.startsWith(it) }
if (isAllowed) continue

violations.add(
SecurityViolation(
file = file,
line = index + 1,
content = stripped,
message = "Remote script inclusion: 'apply from: $url' downloads and executes arbitrary code at build-configuration time. Use a local file or a versioned plugin instead.",
riskLevel = RiskLevel.HIGH,
checkId = id
)
)
}
}
return violations
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ class CredentialLeakCheck : SecurityCheck {
Pattern.compile("(password|passwd|secret|token)\\s*[=:]\\s*[\"']([^\"']+)[\"']", Pattern.CASE_INSENSITIVE)
)

// Gradle Enterprise / Develocity access keys — grant access to build scan data and remote cache
private val gradleEnterprisePatterns = listOf(
Pattern.compile("gradle\\.enterprise\\.accessKey\\s*=\\s*(\\S+)", Pattern.CASE_INSENSITIVE),
Pattern.compile("develocity\\.accessKey\\s*=\\s*(\\S+)", Pattern.CASE_INSENSITIVE),
Pattern.compile("ge\\.accessKey\\s*=\\s*(\\S+)", Pattern.CASE_INSENSITIVE)
)

// High-confidence patterns with recognisable prefixes — no placeholder filtering needed
private val specificPatterns = listOf(
Pattern.compile("(AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[0-9A-Z]{16}"), // AWS key ID
Expand Down Expand Up @@ -71,6 +78,25 @@ class CredentialLeakCheck : SecurityCheck {
}
}

// Gradle Enterprise / Develocity access key
for (pattern in gradleEnterprisePatterns) {
val matcher = pattern.matcher(line)
if (matcher.find()) {
val value = if (matcher.groupCount() >= 1) matcher.group(1) else ""
if (value.isNotBlank() && !isPlaceholder(value)) {
violations.add(
SecurityViolation(
file = file,
line = index + 1,
content = line.trim(),
message = "Gradle Enterprise / Develocity access key detected. This grants access to build scan data and the remote build cache — store it in an environment variable instead.",
riskLevel = RiskLevel.HIGH
)
)
}
}
}

// Specific patterns: report directly, no filtering needed
for (pattern in specificPatterns) {
val matcher = pattern.matcher(line)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.mohammedalaamorsi.safegradle

import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFile

// User-defined checks: place .kts scripts in .safegradle/checks/.
// Each script uses comment directives:
// # PATTERN: <regex> RISK: HIGH|MEDIUM|LOW MSG: <message>
// Lines matching the regex are flagged at the given risk level.
object CustomCheckLoader {

fun loadChecks(project: Project): List<SecurityCheck> {
val baseDir = project.basePath ?: return emptyList()
val checksDir = java.io.File(baseDir, ".safegradle/checks")
if (!checksDir.exists() || !checksDir.isDirectory) return emptyList()

return checksDir.listFiles { f -> f.extension == "kts" }
?.mapNotNull { scriptFile ->
try {
ScriptBasedCheck(scriptFile)
} catch (e: Exception) {
null // skip malformed scripts silently
}
} ?: emptyList()
}
}

class ScriptBasedCheck(private val scriptFile: java.io.File) : SecurityCheck {
override val id = "custom_${scriptFile.nameWithoutExtension}"
override val name = "Custom: ${scriptFile.nameWithoutExtension}"
override val description = "User-defined check loaded from .safegradle/checks/${scriptFile.name}"

// Parse the script once: extract inline patterns from comment directives.
// Supported directive: # PATTERN: <regex> [RISK: HIGH|MEDIUM|LOW] [MSG: <message>]
private data class CustomRule(val pattern: java.util.regex.Pattern, val message: String, val risk: RiskLevel)

private val rules: List<CustomRule> = parseRules()

private fun parseRules(): List<CustomRule> {
val result = mutableListOf<CustomRule>()
scriptFile.readLines().forEach { line ->
val trimmed = line.trim()
if (!trimmed.startsWith("# PATTERN:") && !trimmed.startsWith("// PATTERN:")) return@forEach
try {
val withoutPrefix = trimmed.removePrefix("# PATTERN:").removePrefix("// PATTERN:").trim()
val riskMatch = Regex("""\bRISK:\s*(HIGH|MEDIUM|LOW)\b""", RegexOption.IGNORE_CASE).find(withoutPrefix)
val msgMatch = Regex("""\bMSG:\s*(.+)$""").find(withoutPrefix)
val risk = when (riskMatch?.groupValues?.get(1)?.uppercase()) {
"HIGH" -> RiskLevel.HIGH
"LOW" -> RiskLevel.LOW
else -> RiskLevel.MEDIUM
}
val message = msgMatch?.groupValues?.get(1)?.trim() ?: "Custom rule matched: ${scriptFile.nameWithoutExtension}"
val patternEnd = riskMatch?.range?.first?.minus(1) ?: msgMatch?.range?.first?.minus(1) ?: withoutPrefix.length
val patternStr = withoutPrefix.substring(0, patternEnd).trim()
result.add(CustomRule(java.util.regex.Pattern.compile(patternStr, java.util.regex.Pattern.CASE_INSENSITIVE), message, risk))
} catch (_: Exception) { } // skip malformed directive lines
}
return result
}

override fun check(file: VirtualFile, content: String, project: Project?, teamConfig: YamlConfig?): List<SecurityViolation> {
if (rules.isEmpty()) return emptyList()
val violations = mutableListOf<SecurityViolation>()
content.lines().forEachIndexed { index, line ->
val stripped = line.trim()
if (stripped.startsWith("//") || stripped.startsWith("#")) return@forEachIndexed
for (rule in rules) {
if (rule.pattern.matcher(line).find()) {
violations.add(
SecurityViolation(
file = file,
line = index + 1,
content = stripped,
message = rule.message,
riskLevel = rule.risk,
checkId = id
)
)
}
}
}
return violations
}
}
Loading
Loading