Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/ci-security.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Security scanners

# Scan integration changes and refresh vulnerability intelligence weekly.
on:
pull_request:
branches: [main, develop, maintenance]
types: [opened, synchronize, reopened, edited]
push:
branches: [main, develop, maintenance]
schedule:
- cron: '12 22 * * 5'
workflow_dispatch:

permissions:
contents: read

# Local reuse lets this public helper validate its own review candidate.
jobs:
security:
if: github.event_name != 'pull_request' || github.event.action != 'edited' || github.event.changes.base != null
uses: ./.github/workflows/reusable-public-security.yml
summary-tests:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check out current public source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Exercise privacy and policy boundaries
run: |
python3 -B scripts/security/test_summarize.py
python3 -B scripts/security/test_policy.py
125 changes: 125 additions & 0 deletions .github/workflows/reusable-public-security.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
name: Public security scanners

on:
workflow_call:

# Public callers need only source read access, including for untrusted forks.
permissions:
contents: read

# Both scanners always use fresh GitHub-hosted runners, with no inherited secrets.
jobs:
gitleaks:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Require a public caller
env:
CALLER_PRIVATE: ${{ github.event.repository.private }}
run: test "$CALLER_PRIVATE" = 'false'
- name: Check out caller source without persisted credentials
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
path: source
- name: Check out immutable public scanner utilities
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: PrestonCoreSystems/.github
ref: 291e6ec865eb08e522e7fc04b51124aa58714524
persist-credentials: false
path: scanner-tools
- name: Reject PR scanner policy edits
if: github.event_name == 'pull_request'
working-directory: source
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: bash "$GITHUB_WORKSPACE/scanner-tools/scripts/security/check-policy.sh" "$BASE_SHA"
- name: Install checksum-verified gitleaks 8.30.1
env:
TOOL_SHA256: '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb'
run: |
set -euo pipefail
directory=$(mktemp -d "$RUNNER_TEMP/public-gitleaks.XXXXXX")
curl --fail --location --proto '=https' --tlsv1.2 --max-time 180 \
--output "$directory/tool.tar.gz" 'https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz'
printf '%s %s\n' "$TOOL_SHA256" "$directory/tool.tar.gz" | sha256sum --check --status
tar --extract --gzip --file "$directory/tool.tar.gz" --directory "$directory" gitleaks
chmod 0755 "$directory/gitleaks"
printf '%s\n' "$directory" >> "$GITHUB_PATH"
- name: Scan current source into a private temporary report
id: scan
working-directory: source
env:
REPORT: ${{ runner.temp }}/public-gitleaks-report.json
run: |
umask 077
gitleaks dir --redact --ignore-gitleaks-allow --no-banner --no-color --timeout 120 --exit-code 0 --report-format json --report-path "$REPORT" .
- name: Publish counts and enforce findings
if: always() && steps.scan.outcome == 'success'
env:
REPORT: ${{ runner.temp }}/public-gitleaks-report.json
run: python3 "$GITHUB_WORKSPACE/scanner-tools/scripts/security/summarize.py" 'gitleaks' "$REPORT"
- name: Remove temporary report
if: always()
env:
REPORT: ${{ runner.temp }}/public-gitleaks-report.json
run: rm -f -- "$REPORT"
trivy:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Require a public caller
env:
CALLER_PRIVATE: ${{ github.event.repository.private }}
run: test "$CALLER_PRIVATE" = 'false'
- name: Check out caller source without persisted credentials
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
path: source
- name: Check out immutable public scanner utilities
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: PrestonCoreSystems/.github
ref: 291e6ec865eb08e522e7fc04b51124aa58714524
persist-credentials: false
path: scanner-tools
- name: Reject PR scanner policy edits
if: github.event_name == 'pull_request'
working-directory: source
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: bash "$GITHUB_WORKSPACE/scanner-tools/scripts/security/check-policy.sh" "$BASE_SHA"
- name: Install checksum-verified trivy 0.74.0
env:
TOOL_SHA256: '2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a'
run: |
set -euo pipefail
directory=$(mktemp -d "$RUNNER_TEMP/public-trivy.XXXXXX")
curl --fail --location --proto '=https' --tlsv1.2 --max-time 180 \
--output "$directory/tool.tar.gz" 'https://github.com/aquasecurity/trivy/releases/download/v0.74.0/trivy_0.74.0_Linux-64bit.tar.gz'
printf '%s %s\n' "$TOOL_SHA256" "$directory/tool.tar.gz" | sha256sum --check --status
tar --extract --gzip --file "$directory/tool.tar.gz" --directory "$directory" trivy
chmod 0755 "$directory/trivy"
printf '%s\n' "$directory" >> "$GITHUB_PATH"
- name: Scan current source into a private temporary report
id: scan
working-directory: source
env:
REPORT: ${{ runner.temp }}/public-trivy-report.json
run: |
umask 077
trivy fs --quiet --scanners vuln,secret,misconfig --severity HIGH,CRITICAL --ignore-unfixed --exit-code 0 --format json --output "$REPORT" .
- name: Publish counts and enforce findings
if: always() && steps.scan.outcome == 'success'
env:
REPORT: ${{ runner.temp }}/public-trivy-report.json
run: python3 "$GITHUB_WORKSPACE/scanner-tools/scripts/security/summarize.py" 'trivy' "$REPORT"
- name: Remove temporary report
if: always()
env:
REPORT: ${{ runner.temp }}/public-trivy-report.json
run: rm -f -- "$REPORT"
25 changes: 25 additions & 0 deletions SCANNER-HELPERS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Public scanner helpers

Public repositories use this hosted-only workflow because private reusable sources
cannot be shared with public callers. This code is authored from public upstream
interfaces and contains no private Core source. Consumers must pin the accepted
public workflow commit. No App or repository visibility change is needed.

GitLeaks 8.30.1 scans current content with redaction. Trivy 0.74.0 scans HIGH and
CRITICAL vulnerabilities, secrets and misconfiguration, excluding unfixed
vulnerabilities. Each release archive has a fixed SHA-256 checksum. Only finding
counts reach job logs. Temporary reports are deleted, and no raw reports or source
snippets are uploaded. Invalid or unavailable scans fail instead of claiming clean.

Both jobs always use GitHub-hosted Ubuntu runners and read-only source permissions.
No secrets or configurable shell inputs are accepted. Forks use the same hosted
route. PR scanner policy edits are refused and require a separate reviewed policy
transition. GitLeaks ignores inline allow comments, and newly added inline Trivy
exclusions are refused. Application deployment, paid scanning and required statuses are outside
this workflow.

The organization repository validates its candidate through local reusable-workflow
syntax. Its scanner utility dependency is immutable and separately committed.
The seven focused tests cover private-value omission, unknown reports, finding exits,
immutable-base policy admission and added inline Trivy exclusions. Actual engines
still require source-bound CI after a source change.
22 changes: 22 additions & 0 deletions scripts/security/check-policy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Compare against an actual immutable base; never let a PR weaken its own scanner policy.
set -euo pipefail
base=${1:?An immutable PR base is required}
if ! [[ "$base" =~ ^[0-9a-f]{40}$ ]]; then
printf '%s\n' 'An immutable PR base is required.' >&2
exit 2
fi
git cat-file -e "$base^{commit}"
changed=$(git diff --name-only "$base" HEAD -- .gitleaks.toml .gitleaksignore .trivyignore .trivyignore.yaml trivy.yaml trivy-secret.yaml)
if [[ -n "$changed" ]]; then
printf '%s\n' 'Scanner policy changed in this PR; a separate reviewed policy transition is required.' >&2
exit 1
fi

# Inspect added lines without logging source; inline exclusions also change scanner policy.
directive="trivy:""ignore"
diff=$(git diff --no-ext-diff --unified=0 "$base" HEAD --)
if printf '%s\n' "$diff" | awk -v directive="$directive" '/^\+[^+]/ && index($0, directive) { found=1 } END { exit !found }'; then
printf '%s\n' 'New inline Trivy exclusions require a separate reviewed policy transition.' >&2
exit 1
fi
49 changes: 49 additions & 0 deletions scripts/security/summarize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Publish counts only; secret matches, source snippets and paths stay out of logs."""
import json
from pathlib import Path
import sys


def summarize(tool, report):
"""Reject malformed reports rather than reporting an unknown result as clean."""
if tool == 'gitleaks':
if not isinstance(report, list) or not all(isinstance(x, dict) and isinstance(x.get('RuleID'), str) for x in report):
raise ValueError('invalid report')
return {'scanner': tool, 'findings': len(report)}
if tool != 'trivy' or not isinstance(report, dict) or report.get('SchemaVersion') != 2:
raise ValueError('invalid report')
results = report.get('Results', [])
if not isinstance(results, list):
raise ValueError('invalid results')
counts = {'vulnerability': 0, 'secret': 0, 'misconfiguration': 0}
for result in results:
if not isinstance(result, dict):
raise ValueError('invalid result')
for field, kind in [('Vulnerabilities', 'vulnerability'), ('Secrets', 'secret'), ('Misconfigurations', 'misconfiguration')]:
entries = result.get(field, [])
if not isinstance(entries, list):
raise ValueError('invalid entries')
for entry in entries:
if not isinstance(entry, dict) or entry.get('Severity') not in {'UNKNOWN', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL'}:
raise ValueError('invalid finding')
if entry['Severity'] in {'HIGH', 'CRITICAL'}:
counts[kind] += 1
return {'scanner': tool, 'findings': sum(counts.values()), 'counts': counts}


def main():
"""Only fixed diagnostic text is emitted when reading or parsing fails."""
try:
if len(sys.argv) != 3:
raise ValueError('arguments')
result = summarize(sys.argv[1], json.loads(Path(sys.argv[2]).read_text()))
print(json.dumps(result, sort_keys=True))
return 1 if result['findings'] else 0
except (OSError, ValueError, TypeError, KeyError):
print('Scanner report unavailable or invalid; result is unknown.', file=sys.stderr)
return 2


if __name__ == '__main__':
sys.exit(main())
49 changes: 49 additions & 0 deletions scripts/security/test_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Exercise actual shell admission with isolated Git fixtures and no credentials."""
from pathlib import Path
import subprocess
import tempfile
import unittest

SCRIPT = Path(__file__).with_name('check-policy.sh').resolve()


class PolicyTests(unittest.TestCase):
def test_immutable_base_and_policy_change(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)

def git(*args):
return subprocess.check_output(['git', *args], cwd=root, stderr=subprocess.DEVNULL, text=True).strip()

def commit():
git('add', '.')
git('-c', 'user.name=Scanner Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-m', 'fixture')

git('init')
(root / 'README.md').write_text('Synthetic fixture only.\n')
commit()
base = git('rev-parse', 'HEAD')
for candidate, expected in [(base, 0), ('HEAD', 2), ('a' * 40, 128)]:
result = subprocess.run(['bash', str(SCRIPT), candidate], cwd=root, capture_output=True)
self.assertEqual(result.returncode, expected)
(root / '.gitleaks.toml').write_text('[extend]\nuseDefault = true\n')
commit()
result = subprocess.run(['bash', str(SCRIPT), base], cwd=root, capture_output=True)
self.assertEqual(result.returncode, 1)

def test_added_inline_trivy_exclusion_is_rejected(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
commands = [['git', 'init'], ['git', '-c', 'user.name=Scanner Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '--allow-empty', '-m', 'fixture']]
for command in commands:
subprocess.run(command, cwd=root, check=True, capture_output=True)
base = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=root, text=True).strip()
(root / 'Dockerfile').write_text('#' + 'trivy:' + 'ignore:EXAMPLE\nFROM scratch\n')
subprocess.run(['git', 'add', '.'], cwd=root, check=True)
subprocess.run(['git', '-c', 'user.name=Scanner Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-m', 'fixture'], cwd=root, check=True, capture_output=True)
result = subprocess.run(['bash', str(SCRIPT), base], cwd=root, capture_output=True)
self.assertEqual(result.returncode, 1)


if __name__ == '__main__':
unittest.main()
49 changes: 49 additions & 0 deletions scripts/security/test_summarize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Exercise privacy and fail-closed behavior using synthetic findings only."""
import importlib.util
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest

SCRIPT = Path(__file__).with_name('summarize.py')
SPEC = importlib.util.spec_from_file_location('summary', SCRIPT)
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)


class SummaryTests(unittest.TestCase):
def test_gitleaks_counts_without_values(self):
result = MODULE.summarize('gitleaks', [{'RuleID': 'example', 'Secret': 'PRIVATE_CANARY', 'Match': 'PRIVATE_CANARY'}])
self.assertEqual(result, {'scanner': 'gitleaks', 'findings': 1})
self.assertNotIn('PRIVATE_CANARY', json.dumps(result))

def test_trivy_counts_each_scanner_without_values(self):
row = {'Target': 'PRIVATE_PATH', **{key: [{'Severity': 'HIGH', 'Match': 'PRIVATE_CANARY'}, {'Severity': 'LOW'}] for key in ['Vulnerabilities', 'Secrets', 'Misconfigurations']}}
result = MODULE.summarize('trivy', {'SchemaVersion': 2, 'Results': [row]})
self.assertEqual(result['findings'], 3)
self.assertEqual(set(result['counts'].values()), {1})
self.assertNotIn('PRIVATE', json.dumps(result))

def test_empty_valid_reports(self):
self.assertEqual(MODULE.summarize('gitleaks', [])['findings'], 0)
self.assertEqual(MODULE.summarize('trivy', {'SchemaVersion': 2})['findings'], 0)

def test_malformed_reports_are_unknown(self):
for tool, value in [('other', []), ('gitleaks', {}), ('gitleaks', [None]), ('trivy', {}), ('trivy', {'SchemaVersion': 2, 'Results': None}), ('trivy', {'SchemaVersion': 2, 'Results': [{'Secrets': [{'Severity': 'invented'}]}]})]:
with self.subTest(tool=tool, value=value), self.assertRaises(ValueError):
MODULE.summarize(tool, value)

def test_cli_unknown_and_finding_exits_are_distinct(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'report.json'
for payload, expected in [('[{"RuleID":"example","Secret":"PRIVATE_CANARY"}]', 1), ('[]', 0), ('PRIVATE_CANARY', 2)]:
path.write_text(payload)
result = subprocess.run([sys.executable, str(SCRIPT), 'gitleaks', str(path)], capture_output=True, text=True)
self.assertEqual(result.returncode, expected)
self.assertNotIn('PRIVATE_CANARY', result.stdout + result.stderr)


if __name__ == '__main__':
unittest.main()