1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
name: Secrets scan (public text)
# Public-text guard: scans the body of newly opened / edited issues,
# pull requests, and comments for sensitive-info patterns and posts a
# single warning comment on a match — naming only the pattern
# CATEGORY, never the matched value.
#
# Sibling of the local pre-commit guard at scripts/scan-secrets.sh.
# See SECURITY.md for the pattern set, allow-list convention, and
# threat model.
#
# Design notes (addressing Gemini-3-Pro round-1 findings against the
# original Spec F draft):
#
# - `pull_request_target` (not `pull_request`) is used so the workflow
# runs in the context of the upstream repo and gets a token with
# write scope. Fork-PR `pull_request` runs get a read-only token
# and cannot post comments. (Round-1 finding #2.)
#
# - We DO NOT check out the fork PR's head code. The default checkout
# for `pull_request_target` is the base ref, which is what we want:
# only the event payload's text fields are scanned, and the
# workflow never executes untrusted code. The Python scanner lives
# in `.github/scripts/`, which is part of the base-ref tree.
#
# - The scanner step writes findings to $GITHUB_OUTPUT in heredoc
# form. `print()` does not set step outputs by itself. (Round-1
# finding #1.)
#
# - The github-script step is a complete `github.rest.issues.createComment`
# payload that branches on `github.event_name` to pick the right
# issue/PR number. (Round-1 finding #3.)
#
# - The Python scanner's allow-list checks the WHOLE LINE for
# placeholder syntax rather than the regex match span, fixing the
# `m.group(0)` mistake. (Round-1 finding #4.)
on:
issues:
types:
issue_comment:
types:
pull_request_target:
types:
pull_request_review_comment:
types:
permissions:
issues: write
pull-requests: write
contents: read
# Concurrency: one scan per (event-target) at a time so a fast edit
# storm collapses into the final state rather than racing comments.
concurrency:
group: secrets-scan-${{ github.event_name }}-${{ github.event.issue.number || github.event.pull_request.number }}
cancel-in-progress: true
jobs:
scan:
name: Scan public text for sensitive-info patterns
runs-on: ubuntu-latest
steps:
- name: Checkout base ref
# For `pull_request_target` this checks out the BASE branch
# (not the fork PR head). For issue/comment events the
# default-branch checkout is fine. Either way we are running
# the scanner from a trusted tree.
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.x"
- name: Scan event text for sensitive-info patterns
id: scan
env:
# The scanner reads TEXT_SCAN_TITLE + TEXT_SCAN_BODY. Each
# event type maps to a different pair of fields. Unmapped
# fields are empty strings.
TEXT_SCAN_TITLE: |
${{ github.event.issue.title }}${{ github.event.pull_request.title }}
TEXT_SCAN_BODY: |
${{ github.event.issue.body }}${{ github.event.pull_request.body }}${{ github.event.comment.body }}
run: |
set +e
python3 .github/scripts/scan-text-for-secrets.py
rc=$?
set -e
# Exit 0 from THIS step regardless: the github-script step
# branches on steps.scan.outputs.findings to decide whether
# to post a comment. Failing this step would prevent the
# follow-up comment from running.
echo "rc=$rc"
exit 0
- name: Post warning comment on findings
if: steps.scan.outputs.findings != ''
uses: actions/github-script@v8
env:
FINDINGS: ${{ steps.scan.outputs.findings }}
with:
script: |
const event = context.eventName;
const findings = process.env.FINDINGS || '';
// Parse "CATEGORY: <name>" lines from the scanner output.
// Anything that does not start with "CATEGORY: " is
// dropped defensively; we never echo arbitrary text.
const categories = findings
.split('\n')
.map(s => s.trim())
.filter(s => s.startsWith('CATEGORY: '))
.map(s => s.slice('CATEGORY: '.length))
.filter(s => /^[a-z_]+$/.test(s));
if (categories.length === 0) {
core.info('No valid categories parsed; skipping comment.');
return;
}
const kind = (() => {
switch (event) {
case 'issues': return 'issue';
case 'issue_comment': return 'comment';
case 'pull_request_target': return 'pull request';
case 'pull_request_review_comment': return 'review comment';
default: return 'submission';
}
})();
const issueNumber =
context.payload.issue?.number ||
context.payload.pull_request?.number;
if (!issueNumber) {
core.warning(`No issue/PR number on event ${event}; cannot post comment.`);
return;
}
const bullets = categories.map(c => `- \`${c}\``).join('\n');
const body = [
':rotating_light: **Sensitive-info patterns detected**',
'',
`The text of this ${kind} appears to match patterns from the lihaaf sensitive-info guard:`,
'',
bullets,
'',
'Please redact and edit. The local pre-commit hook',
'(`scripts/install-pre-commit-hook.sh`) catches these before push.',
'See `SECURITY.md` for the pattern set, the `<word>` placeholder',
'allow-list, and the bypass mechanism.',
].join('\n');
// PRs are issues in the GitHub REST API for the
// "conversation" comment thread. We post on the PR
// conversation rather than a per-line review comment so
// the warning is visible regardless of which surface
// triggered the scan.
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body,
});
core.info(`Posted sensitive-info warning on #${issueNumber} (${kind}).`);