Skip to content

Commit 23120e5

Browse files
🔒 Add Gitleaks Action for Secret Scanning (#40)
* gitleaks action + caller Signed-off-by: Roman Trofimenkov <[email protected]> * Add workflow_dispatch inputs to gitleaks action Signed-off-by: Roman Trofimenkov <[email protected]> * Add gitleaks self-test workflow configuration Signed-off-by: Roman Trofimenkov <[email protected]> * action fix Signed-off-by: Roman Trofimenkov <[email protected]> * path fix Signed-off-by: Roman Trofimenkov <[email protected]> * added leaks finding in job output Signed-off-by: Roman Trofimenkov <[email protected]> * gitleaks action: deleted unneccessary envs Signed-off-by: Roman Trofimenkov <[email protected]> * added README.md Signed-off-by: Roman Trofimenkov <[email protected]> * Remove Gitleaks self-test workflow and update action to support configurable Gitleaks version. Enhance README with optional config details and usage examples. Signed-off-by: Roman Trofimenkov <[email protected]> * (gitleaks): allow diff scan under pull_request_target Signed-off-by: Roman Trofimenkov <[email protected]> * refactor(summary): rename section to "Secret findings" and remove result limit note Signed-off-by: Roman Trofimenkov <[email protected]> * fix(gitleaks): scan PR head vs base on pull_request_target Signed-off-by: Roman Trofimenkov <[email protected]> * feat(gitleaks): use tree-only mode for diff scans Switch diff mode to --no-git to avoid false positives from git history. Full mode remains unchanged for complete repository audits. Signed-off-by: Roman Trofimenkov <[email protected]> * feat(gitleaks): enhance diff scan with PR patch collection Implement a new step to collect added and modified files in PRs, allowing for a more targeted scan of changes. The patch map is generated to filter findings based on added lines, improving the accuracy of results in diff mode. Signed-off-by: Roman Trofimenkov <[email protected]> * output fix Signed-off-by: Roman Trofimenkov <[email protected]> * syntax fix Signed-off-by: Roman Trofimenkov <[email protected]> * stdoutfix Signed-off-by: Roman Trofimenkov <[email protected]> * output fix Signed-off-by: Roman Trofimenkov <[email protected]> * clean Signed-off-by: Roman Trofimenkov <[email protected]> * README update Signed-off-by: Roman Trofimenkov <[email protected]> --------- Signed-off-by: Roman Trofimenkov <[email protected]>
1 parent 214b1a0 commit 23120e5

File tree

2 files changed

+361
-0
lines changed

2 files changed

+361
-0
lines changed

gitleaks/README.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# 🕵️ Gitleaks GitHub Action
2+
3+
## 📌 Purpose
4+
5+
GitHub Action for automatic secret scanning in code using [Gitleaks](https://github.com/gitleaks/gitleaks). Prevents leakage of tokens, keys, passwords, and other secrets into the repository.
6+
7+
## ⚙️ Operation Modes
8+
9+
### Diff scan (primary mode)
10+
- **Automatically integrated** into general PR validation
11+
- Scans **only changed files** and **only added lines** in PR
12+
- Does not analyze commit history — eliminates false positives
13+
- Does not check unchanged files — focuses on new code
14+
- Uses `--no-git` for fast scanning
15+
16+
### Full scan (additional mode)
17+
- Runs on schedule or manually
18+
- Scans the entire repository
19+
- Suitable for periodic security audits
20+
21+
## 🚀 Usage
22+
23+
### Automatic Integration
24+
25+
Diff scan is already integrated into general PR validation and works automatically. No additional configuration required.
26+
27+
### Full Scanning (optional)
28+
29+
If you need full scan, add to `.github/workflows/security-scan.yml`:
30+
31+
```yaml
32+
name: Security Scan
33+
34+
on:
35+
schedule:
36+
- cron: "0 2 * * *" # daily at 02:00 UTC
37+
workflow_dispatch: {} # manual trigger
38+
39+
permissions:
40+
contents: read
41+
42+
jobs:
43+
gitleaks-full:
44+
runs-on: ubuntu-latest
45+
steps:
46+
- uses: deckhouse/modules-actions/gitleaks@main
47+
with:
48+
scan_mode: full
49+
```
50+
51+
### Configuration (optional)
52+
53+
To configure scanning rules, create `gitleaks.toml` in the repository root:
54+
📎 <https://github.com/gitleaks/gitleaks/blob/main/config/gitleaks.toml>
55+
56+
Without config, built-in Gitleaks rules are used.
57+
58+
## 📝 Parameters
59+
60+
| Parameter | Description | Default |
61+
|-----------|-------------|---------|
62+
| `scan_mode` | Mode: `diff` or `full` | `full` |
63+
| `gitleaks_version` | Gitleaks version | `v8.28.0` |
64+
| `checkout_repo` | Repository for checkout | `${{ github.repository }}` |
65+
| `checkout_ref` | SHA for checkout | `""` |
66+
| `base_sha` | Base SHA for diff | `""` |
67+
68+
## 🔧 Technical Features
69+
70+
### Patch-based scanning (diff mode)
71+
- Collects only changed files from PR
72+
- Creates temporary tree with these files
73+
- Scans without git history (`--no-git`)
74+
- Filters findings only by added lines
75+
76+
### Benefits
77+
- **Minimal false positives** — doesn't find deleted secrets
78+
- **Fast operation** — scans only changes
79+
- **Accuracy** — focuses on new code in PR
80+
81+
## 🐛 Troubleshooting
82+
83+
**Many false positives**: use `diff` mode for PR checks
84+
**Workflow fails**: check `contents: read` permissions
85+
**Need configuration**: create `gitleaks.toml` in repository root

gitleaks/action.yml

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
name: "Gitleaks scan"
2+
description: "Run Gitleaks in full or diff mode (composite action, configurable version)"
3+
4+
inputs:
5+
scan_mode:
6+
description: "Scan mode: full | diff"
7+
required: false
8+
default: "full"
9+
gitleaks_version:
10+
description: "Gitleaks version to install"
11+
required: false
12+
default: "v8.28.0"
13+
checkout_repo:
14+
description: "owner/repo to checkout (PR head)"
15+
required: false
16+
default: "${{ github.repository }}"
17+
checkout_ref:
18+
description: "ref/sha to checkout (PR head SHA)"
19+
required: false
20+
default: ""
21+
base_sha:
22+
description: "Base SHA for diff (PR base SHA)"
23+
required: false
24+
default: ""
25+
26+
runs:
27+
using: "composite"
28+
steps:
29+
- name: Checkout (PR head or default)
30+
uses: actions/checkout@v4
31+
with:
32+
repository: ${{ inputs.checkout_repo }}
33+
ref: ${{ inputs.checkout_ref }}
34+
fetch-depth: 0
35+
persist-credentials: false
36+
37+
- name: Install Gitleaks
38+
shell: bash
39+
run: |
40+
set -euo pipefail
41+
ver="${{ inputs.gitleaks_version }}"
42+
file_ver="${ver#v}"
43+
arch="$(uname -m)"
44+
case "$arch" in
45+
x86_64|amd64) pkg_arch="linux_x64" ;;
46+
aarch64|arm64) pkg_arch="linux_arm64" ;;
47+
*) echo "Unsupported arch: $arch"; exit 1 ;;
48+
esac
49+
base="https://github.com/gitleaks/gitleaks/releases/download/${ver}"
50+
tgz="gitleaks_${file_ver}_${pkg_arch}.tar.gz"
51+
curl -sSL "$base/$tgz" -o gitleaks.tgz
52+
tar -xzf gitleaks.tgz gitleaks
53+
chmod +x gitleaks
54+
install_dir="$HOME/.local/bin"; mkdir -p "$install_dir"
55+
mv gitleaks "$install_dir/gitleaks"
56+
echo "$install_dir" >> "$GITHUB_PATH"
57+
gitleaks version
58+
59+
- name: Check for optional config
60+
id: config
61+
shell: bash
62+
run: |
63+
set -euo pipefail
64+
if [[ -f "gitleaks.toml" ]]; then
65+
echo "config_arg=-c gitleaks.toml" >> "$GITHUB_OUTPUT"
66+
echo "✅ Found config: gitleaks.toml"
67+
else
68+
echo "config_arg=" >> "$GITHUB_OUTPUT"
69+
echo "⚠️ Config file not found. Proceeding with default rules."
70+
fi
71+
72+
- name: Collect PR patch (files & added lines)
73+
id: prpatch
74+
if: ${{ inputs.scan_mode == 'diff' }}
75+
shell: bash
76+
run: |
77+
set -euo pipefail
78+
79+
HEAD_SHA="$(git rev-parse HEAD)"
80+
BASE_SHA="${{ inputs.base_sha }}"
81+
if [[ -z "${BASE_SHA}" && -n "${GITHUB_BASE_REF:-}" ]]; then
82+
git fetch --no-tags origin "${GITHUB_BASE_REF}:${GITHUB_BASE_REF}"
83+
BASE_SHA="$(git rev-parse "origin/${GITHUB_BASE_REF}")"
84+
fi
85+
86+
echo "head_sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
87+
echo "base_sha=${BASE_SHA}" >> "$GITHUB_OUTPUT"
88+
89+
if [[ -z "${BASE_SHA}" ]]; then
90+
echo "No BASE SHA for PR; empty patch."
91+
echo "src_dir=" >> "$GITHUB_OUTPUT"
92+
echo "patch_map=" >> "$GITHUB_OUTPUT"
93+
exit 0
94+
fi
95+
96+
mapfile -t FILES < <(git diff --name-only --diff-filter=AMR "${BASE_SHA}" "${HEAD_SHA}")
97+
if (( ${#FILES[@]} == 0 )); then
98+
echo "No changed files."
99+
echo "src_dir=" >> "$GITHUB_OUTPUT"
100+
echo "patch_map=" >> "$GITHUB_OUTPUT"
101+
exit 0
102+
fi
103+
104+
SRC_DIR="$(mktemp -d)"
105+
for f in "${FILES[@]}"; do
106+
[[ -f "$f" ]] || continue
107+
mkdir -p "${SRC_DIR}/$(dirname "$f")"
108+
cp "$f" "${SRC_DIR}/$f"
109+
done
110+
111+
PATCH_JSON="$(mktemp)"
112+
echo '{}' > "$PATCH_JSON"
113+
114+
while IFS= read -r file; do
115+
HUNKS="$(git diff --unified=0 "${BASE_SHA}" "${HEAD_SHA}" -- "$file" \
116+
| awk '/^@@/ {print}' \
117+
| sed -n 's/.*+\([0-9]\+\),\([0-9]\+\).*/\1 \2/p')"
118+
119+
RANGES=()
120+
while read -r start count; do
121+
[[ -z "${start:-}" || -z "${count:-}" ]] && continue
122+
end=$((start + count - 1))
123+
RANGES+=("[${start},${end}]")
124+
done <<< "$HUNKS"
125+
126+
if (( ${#RANGES[@]} > 0 )); then
127+
jq --arg f "$file" --argjson arr "[$(IFS=,; echo "${RANGES[*]}")]" \
128+
'. + {($f): $arr}' "$PATCH_JSON" > "$PATCH_JSON.tmp"
129+
mv "$PATCH_JSON.tmp" "$PATCH_JSON"
130+
fi
131+
done <<< "$(printf '%s\n' "${FILES[@]}")"
132+
133+
echo "src_dir=${SRC_DIR}" >> "$GITHUB_OUTPUT"
134+
echo "patch_map=${PATCH_JSON}" >> "$GITHUB_OUTPUT"
135+
136+
- name: Gitleaks detect (full)
137+
if: ${{ inputs.scan_mode == 'full' }}
138+
shell: bash
139+
run: |
140+
set -euo pipefail
141+
CONFIG_ARG="${{ steps.config.outputs.config_arg }}"
142+
gitleaks detect --no-banner \
143+
--report-format json --report-path gitleaks.json \
144+
$CONFIG_ARG \
145+
--source .
146+
147+
- name: Gitleaks detect (patch tree-only)
148+
if: ${{ inputs.scan_mode == 'diff' }}
149+
shell: bash
150+
run: |
151+
set -euo pipefail
152+
SRC="${{ steps.prpatch.outputs.src_dir }}"
153+
CONFIG_ARG="${{ steps.config.outputs.config_arg }}"
154+
if [[ -z "${SRC}" ]]; then
155+
echo "[]" > gitleaks.json
156+
exit 0
157+
fi
158+
gitleaks detect --no-banner --no-git \
159+
--report-format json --report-path gitleaks.json \
160+
$CONFIG_ARG \
161+
--source "${SRC}"
162+
163+
- name: Filter findings by added lines (patch)
164+
if: ${{ inputs.scan_mode == 'diff' }}
165+
shell: bash
166+
run: |
167+
set -euo pipefail
168+
PATCH_MAP_PATH="${{ steps.prpatch.outputs.patch_map }}"
169+
[[ -f "gitleaks.json" ]] || { echo "[]">gitleaks.json; exit 0; }
170+
171+
if [[ -z "${PATCH_MAP_PATH}" || ! -s "${PATCH_MAP_PATH}" ]]; then
172+
echo "[]" > gitleaks.json
173+
exit 0
174+
fi
175+
176+
MAP_JSON="$(cat "${PATCH_MAP_PATH}")"
177+
178+
jq --argjson map "${MAP_JSON}" '
179+
def arr: if type=="object" and has("findings") then .findings
180+
elif type=="array" then . else [] end;
181+
arr
182+
| map(
183+
. as $f
184+
| ($f.File // $f.file // $f.Target // $f.Location.File // "") as $file
185+
| ($f.StartLine // $f.Line // $f.Location.StartLine // 0) as $line
186+
| if ($map[$file] // empty) as $ranges
187+
| any($ranges[]; $line >= .[0] and $line <= .[1])
188+
then $f else empty end
189+
)
190+
' gitleaks.json > gitleaks.filtered.json
191+
192+
mv gitleaks.filtered.json gitleaks.json
193+
echo "Filtered to added lines."
194+
195+
- name: Print findings (always)
196+
if: always()
197+
shell: bash
198+
env:
199+
SRC_DIR: ${{ steps.prpatch.outputs.src_dir }}
200+
HEAD_SHA: ${{ steps.prpatch.outputs.head_sha }}
201+
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
202+
run: |
203+
set -euo pipefail
204+
[[ -f gitleaks.json ]] || { echo "No gitleaks.json produced"; exit 0; }
205+
LIMIT=200
206+
207+
JQ_FILTER='
208+
def norm:
209+
{
210+
file: (.File // .file // .Target // .Location.File // ""),
211+
line: (.StartLine // .Line // .Location.StartLine // 0),
212+
rule: (.RuleID // .Rule // .Description // ""),
213+
commit: (.Commit // .commit // "")
214+
};
215+
(if type=="object" and has("findings") then .findings
216+
elif type=="array" then .
217+
else [] end)[] | norm
218+
'
219+
220+
COUNT=$(jq -r 'if type=="object" and has("findings") then (.findings|length)
221+
elif type=="array" then length else 0 end' gitleaks.json)
222+
echo "Findings: $COUNT"
223+
224+
jq -r "$JQ_FILTER | [.rule, .file, (.line|tostring), (.commit // \"\")] | @tsv" gitleaks.json \
225+
| head -n "$LIMIT" \
226+
| while IFS=$'\t' read -r rule file line commit; do
227+
rel="$file"
228+
if [[ -n "${SRC_DIR:-}" && "$file" == "${SRC_DIR%/}/"* ]]; then
229+
rel="${file#${SRC_DIR%/}/}"
230+
fi
231+
sha="${commit:-${HEAD_SHA:-}}"
232+
link=""
233+
if [[ -n "$sha" && -n "$rel" && "$line" =~ ^[0-9]+$ ]]; then
234+
link="${REPO_URL}/blob/${sha}/${rel}#L${line}"
235+
fi
236+
printf '• %s %s %s %s\n' "[$rule]" "${rel}:${line}" "${sha:0:7}" "${link}"
237+
done
238+
239+
{
240+
echo "### Secret findings :closed_lock_with_key:"
241+
echo
242+
echo "| Rule | File:Line | Commit | Link |"
243+
echo "|---|---|---|---|"
244+
jq -r "$JQ_FILTER | [.rule, .file, (.line|tostring), (.commit // \"\")] | @tsv" gitleaks.json \
245+
| head -n "$LIMIT" \
246+
| while IFS=$'\t' read -r rule file line commit; do
247+
rel="$file"
248+
if [[ -n "${SRC_DIR:-}" && "$file" == "${SRC_DIR%/}/"* ]]; then
249+
rel="${file#${SRC_DIR%/}/}"
250+
fi
251+
sha="${commit:-${HEAD_SHA:-}}"
252+
link="-"
253+
if [[ -n "$sha" && -n "$rel" && "$line" =~ ^[0-9]+$ ]]; then
254+
link="${REPO_URL}/blob/${sha}/${rel}#L${line}"
255+
fi
256+
printf '| `%s` | `%s:%s` | `%s` | %s |\n' "$rule" "$rel" "$line" "${sha:0:7}" "$link"
257+
done
258+
259+
if (( COUNT > LIMIT )); then
260+
MORE=$((COUNT - LIMIT))
261+
echo
262+
echo "_... and $MORE more (see artifact **gitleaks-report**)._"
263+
fi
264+
} >> "$GITHUB_STEP_SUMMARY"
265+
266+
if (( COUNT > 0 )); then
267+
exit 1
268+
fi
269+
270+
- name: Upload report (always)
271+
if: always()
272+
uses: actions/upload-artifact@v4
273+
with:
274+
name: gitleaks-report
275+
path: gitleaks.json
276+
if-no-files-found: ignore

0 commit comments

Comments
 (0)