diff --git a/.github/workflows/ci-security.yml b/.github/workflows/ci-security.yml new file mode 100644 index 0000000..4d248bf --- /dev/null +++ b/.github/workflows/ci-security.yml @@ -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 diff --git a/.github/workflows/reusable-public-security.yml b/.github/workflows/reusable-public-security.yml new file mode 100644 index 0000000..67e1541 --- /dev/null +++ b/.github/workflows/reusable-public-security.yml @@ -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" diff --git a/SCANNER-HELPERS.md b/SCANNER-HELPERS.md new file mode 100644 index 0000000..731aea9 --- /dev/null +++ b/SCANNER-HELPERS.md @@ -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. diff --git a/scripts/security/check-policy.sh b/scripts/security/check-policy.sh new file mode 100644 index 0000000..b8a2db4 --- /dev/null +++ b/scripts/security/check-policy.sh @@ -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 diff --git a/scripts/security/summarize.py b/scripts/security/summarize.py new file mode 100644 index 0000000..4e2df2d --- /dev/null +++ b/scripts/security/summarize.py @@ -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()) diff --git a/scripts/security/test_policy.py b/scripts/security/test_policy.py new file mode 100644 index 0000000..173a913 --- /dev/null +++ b/scripts/security/test_policy.py @@ -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() diff --git a/scripts/security/test_summarize.py b/scripts/security/test_summarize.py new file mode 100644 index 0000000..f187181 --- /dev/null +++ b/scripts/security/test_summarize.py @@ -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()