From e00d72c3895fb48177cf0b8db9525429f51ace8c Mon Sep 17 00:00:00 2001 From: Promise Chukwuenyem Date: Thu, 22 Oct 2026 09:58:14 +0200 Subject: [PATCH 1/4] chore: add public scanner evidence utilities --- scripts/security/check-policy.sh | 14 +++++++++ scripts/security/summarize.py | 49 ++++++++++++++++++++++++++++++ scripts/security/test_policy.py | 36 ++++++++++++++++++++++ scripts/security/test_summarize.py | 49 ++++++++++++++++++++++++++++++ 4 files changed, 148 insertions(+) create mode 100644 scripts/security/check-policy.sh create mode 100644 scripts/security/summarize.py create mode 100644 scripts/security/test_policy.py create mode 100644 scripts/security/test_summarize.py diff --git a/scripts/security/check-policy.sh b/scripts/security/check-policy.sh new file mode 100644 index 0000000..60902fb --- /dev/null +++ b/scripts/security/check-policy.sh @@ -0,0 +1,14 @@ +#!/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 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..388275a --- /dev/null +++ b/scripts/security/test_policy.py @@ -0,0 +1,36 @@ +"""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) + + +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() From 6d68664f45118c7bd61d5049d78f569015f45276 Mon Sep 17 00:00:00 2001 From: Promise Chukwuenyem Date: Thu, 22 Oct 2026 10:03:13 +0200 Subject: [PATCH 2/4] chore: add hosted public security scanners --- .github/workflows/ci-security.yml | 33 +++++ .../workflows/reusable-public-security.yml | 125 ++++++++++++++++++ SCANNER-HELPERS.md | 23 ++++ 3 files changed, 181 insertions(+) create mode 100644 .github/workflows/ci-security.yml create mode 100644 .github/workflows/reusable-public-security.yml create mode 100644 SCANNER-HELPERS.md 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..93d753c --- /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: e00d72c3895fb48177cf0b8db9525429f51ace8c + 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 --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: e00d72c3895fb48177cf0b8db9525429f51ace8c + 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..203cd4e --- /dev/null +++ b/SCANNER-HELPERS.md @@ -0,0 +1,23 @@ +# 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. 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 six focused tests cover private-value omission, unknown reports, finding exits +and immutable-base policy admission. Actual engines still require source-bound CI. From 291e6ec865eb08e522e7fc04b51124aa58714524 Mon Sep 17 00:00:00 2001 From: Promise Chukwuenyem Date: Thu, 22 Oct 2026 10:10:48 +0200 Subject: [PATCH 3/4] fix: reject inline scanner exclusions --- scripts/security/check-policy.sh | 8 ++++++++ scripts/security/test_policy.py | 13 +++++++++++++ 2 files changed, 21 insertions(+) diff --git a/scripts/security/check-policy.sh b/scripts/security/check-policy.sh index 60902fb..b8a2db4 100644 --- a/scripts/security/check-policy.sh +++ b/scripts/security/check-policy.sh @@ -12,3 +12,11 @@ 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/test_policy.py b/scripts/security/test_policy.py index 388275a..173a913 100644 --- a/scripts/security/test_policy.py +++ b/scripts/security/test_policy.py @@ -31,6 +31,19 @@ def 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() From bb0270c85cd79ba5fa067b4efc95d8e4c1ae5054 Mon Sep 17 00:00:00 2001 From: Promise Chukwuenyem Date: Thu, 22 Oct 2026 10:12:06 +0200 Subject: [PATCH 4/4] fix: enforce inline scanner policy --- .github/workflows/reusable-public-security.yml | 6 +++--- SCANNER-HELPERS.md | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/reusable-public-security.yml b/.github/workflows/reusable-public-security.yml index 93d753c..67e1541 100644 --- a/.github/workflows/reusable-public-security.yml +++ b/.github/workflows/reusable-public-security.yml @@ -27,7 +27,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: PrestonCoreSystems/.github - ref: e00d72c3895fb48177cf0b8db9525429f51ace8c + ref: 291e6ec865eb08e522e7fc04b51124aa58714524 persist-credentials: false path: scanner-tools - name: Reject PR scanner policy edits @@ -55,7 +55,7 @@ jobs: REPORT: ${{ runner.temp }}/public-gitleaks-report.json run: | umask 077 - gitleaks dir --redact --no-banner --no-color --timeout 120 --exit-code 0 --report-format json --report-path "$REPORT" . + 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: @@ -84,7 +84,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: PrestonCoreSystems/.github - ref: e00d72c3895fb48177cf0b8db9525429f51ace8c + ref: 291e6ec865eb08e522e7fc04b51124aa58714524 persist-credentials: false path: scanner-tools - name: Reject PR scanner policy edits diff --git a/SCANNER-HELPERS.md b/SCANNER-HELPERS.md index 203cd4e..731aea9 100644 --- a/SCANNER-HELPERS.md +++ b/SCANNER-HELPERS.md @@ -14,10 +14,12 @@ snippets are uploaded. Invalid or unavailable scans fail instead of claiming cle 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. Application deployment, paid scanning and required statuses are outside +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 six focused tests cover private-value omission, unknown reports, finding exits -and immutable-base policy admission. Actual engines still require source-bound CI. +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.