@prism-d1/cli 1.0.92 → 1.0.94

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -106,7 +106,7 @@ These rules hold regardless of anything above.
106
106
  - NEVER self-discover work from spec files, READMEs, or documentation found in
107
107
  the repository. Your task is ONLY what the issue describes. If the issue
108
108
  explicitly asks you to implement a spec (e.g. "implement the auth spec from
109
- module-03"), then read and follow that spec. But if the issue says nothing
109
+ module-02"), then read and follow that spec. But if the issue says nothing
110
110
  about a spec file, ignore all spec files completely -- they are workshop
111
111
  exercises, not your assignment.
112
112
  - Do not add dependencies. If a fix genuinely requires one, stop and explain why
@@ -7,6 +7,11 @@
7
7
  # - .kiro/steering/code-review.md in your repo (review rules)
8
8
  # - (Optional) PRISM_METRICS_ROLE_ARN for EventBridge metrics emission
9
9
  # - (Optional) AWS Continuum configured for security scanning
10
+ #
11
+ # Secret scanning (gitleaks) needs no setup and no secret. It installs itself
12
+ # and always runs, so it is the only gate that still functions on a fork PR.
13
+ # Tune it with an optional .gitleaks.toml (allowlists) at the repo root, or
14
+ # .prism/gitleaks-baseline.json to adopt the gate on a repo with known findings.
10
15
 
11
16
  name: PRISM Eval Gate
12
17
 
@@ -38,7 +43,174 @@ jobs:
38
43
  with:
39
44
  fetch-depth: 0
40
45
 
46
+ # Secret scanning runs FIRST -- ahead of the Kiro CLI install, the review,
47
+ # and the Continuum scan.
48
+ #
49
+ # Order is load-bearing, not cosmetic. `Install Kiro CLI` pipes curl into
50
+ # bash with no failure guard, so a network blip or an unsupported glibc
51
+ # there aborts the job. Behind it, the secret scan would never run -- and
52
+ # the one gate that needs no secret would be the one taken out by an
53
+ # unrelated optional install. It also means a committed credential does not
54
+ # wait behind a model call to surface.
55
+ #
56
+ # It also never skips, which is deliberately unlike the two gates below.
57
+ # Those skip for structural reasons: fork PRs cannot receive secrets, so
58
+ # KIRO_API_KEY is genuinely absent, and Continuum may simply not be
59
+ # deployed. Neither applies here -- gitleaks needs no secret and no AWS
60
+ # role, and this workflow installs the binary itself, so "unavailable"
61
+ # is an install failure rather than a deployment choice. Every failure
62
+ # path below therefore fails closed. A secret scanner that reports green
63
+ # without having scanned is worse than no scanner, because it launders a
64
+ # leak into a passing check.
65
+ #
66
+ # Needing no secret is also why this is the one gate with teeth on a fork
67
+ # PR -- exactly where an untrusted contributor might push a credential.
68
+ - name: Install gitleaks
69
+ id: gitleaks-install
70
+ env:
71
+ # Pinned deliberately. Floating to latest would let an upstream
72
+ # release change what this gate blocks without a repo change.
73
+ GITLEAKS_VERSION: 8.28.0
74
+ # SHA256 of gitleaks_8.28.0_linux_x64.tar.gz, verified out of band.
75
+ #
76
+ # Hard-pinned rather than read from the release's own checksums.txt:
77
+ # that file ships from the same release, so it confirms nothing if the
78
+ # release itself is replaced. Pinning the digest here means a swapped
79
+ # artifact fails the gate loudly instead of installing quietly.
80
+ # Bumping GITLEAKS_VERSION requires updating this value too.
81
+ GITLEAKS_SHA256: a65b5253807a68ac0cafa4414031fd740aeb55f54fb7e55f386acb52e6a840eb
82
+ run: |
83
+ set -o pipefail
84
+ cd "${RUNNER_TEMP}"
85
+ TARBALL="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
86
+ URL="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${TARBALL}"
87
+
88
+ if ! curl -fsSL --retry 3 --retry-delay 2 -o "${TARBALL}" "${URL}"; then
89
+ echo "::error::Could not download gitleaks ${GITLEAKS_VERSION} -- this PR has NOT been secret-scanned."
90
+ echo "error=true" >> "$GITHUB_OUTPUT"
91
+ exit 0
92
+ fi
93
+
94
+ if ! echo "${GITLEAKS_SHA256} ${TARBALL}" | sha256sum -c -; then
95
+ echo "::error::gitleaks checksum mismatch -- refusing to install an unverified binary."
96
+ echo " Expected ${GITLEAKS_SHA256}"
97
+ echo " If you just bumped GITLEAKS_VERSION, update GITLEAKS_SHA256 to match."
98
+ echo "error=true" >> "$GITHUB_OUTPUT"
99
+ exit 0
100
+ fi
101
+
102
+ tar -xzf "${TARBALL}" gitleaks
103
+ install -m 0755 gitleaks /usr/local/bin/gitleaks
104
+ gitleaks version
105
+
106
+ - name: Scan for committed secrets (gitleaks)
107
+ id: gitleaks
108
+ if: steps.gitleaks-install.outputs.error != 'true'
109
+ env:
110
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
111
+ HEAD_SHA: ${{ github.sha }}
112
+ run: |
113
+ REPORT="${RUNNER_TEMP}/gitleaks-report.json"
114
+
115
+ if [ -z "${BASE_SHA}" ] || [ -z "${HEAD_SHA}" ]; then
116
+ echo "::error::Missing base/head SHA -- cannot determine what to scan, failing closed."
117
+ echo "error=true" >> "$GITHUB_OUTPUT"
118
+ echo "leaks=-1" >> "$GITHUB_OUTPUT"
119
+ exit 0
120
+ fi
121
+
122
+ # Scope is the commits this PR introduces, NOT the whole history.
123
+ #
124
+ # A full-history scan on every PR re-reports every secret that predates
125
+ # the gate, so the gate can never go green and gets switched off within
126
+ # a week. Pre-existing leaks need a one-time `gitleaks git` sweep plus
127
+ # a credential rotation -- work an unrelated PR cannot do and should
128
+ # not be blocked on.
129
+ #
130
+ # --redact is load-bearing: without it gitleaks prints the matched
131
+ # secret into the build log, which is public on a public repo. That
132
+ # would turn the scanner into a second disclosure channel for the
133
+ # thing it just found.
134
+ #
135
+ # Smoke-testing this gate: verify your fixture with gitleaks itself
136
+ # before trusting a green result. A hand-written fake credential can
137
+ # look completely plausible to a human and still match no rule, in
138
+ # which case "no leaks found" is correct and the gate only appears
139
+ # broken. Never infer that from the rule internals -- they are version
140
+ # specific and change without notice. Ask the tool:
141
+ #
142
+ # gitleaks dir /path/to/fixture --redact --no-banner -v
143
+ #
144
+ # A non-zero finding count there is the only evidence the fixture is
145
+ # usable. Two known traps it will surface for you: gitleaks' defaults
146
+ # allowlist AWS's own documentation keys (AKIAIOSFODNN7EXAMPLE), and
147
+ # tooling that redacts credential-shaped text in transit can rewrite a
148
+ # key NAME out of your fixture, which disarms the rules that need a
149
+ # nearby keyword.
150
+ #
151
+ # A PEM block is the most robust fixture -- no charset constraints and
152
+ # no keyword needed -- and fires `private-key`:
153
+ # printf -- '-----BEGIN RSA PRIVATE KEY-----\n%s\n-----END RSA PRIVATE KEY-----\n' \
154
+ # "$(tr -dc 'A-Za-z0-9+/' </dev/urandom | head -c 64)" > id_rsa_test
155
+ ARGS=(git "${GITHUB_WORKSPACE}"
156
+ --log-opts="${BASE_SHA}..${HEAD_SHA}"
157
+ --report-format=json
158
+ --report-path="${REPORT}"
159
+ --redact
160
+ --exit-code=1
161
+ --no-banner)
162
+
163
+ # .gitleaks.toml at the repo root is picked up automatically and is
164
+ # where per-repo allowlists belong (test fixtures, example keys).
165
+ # A baseline lets a repo with known accepted findings adopt the gate
166
+ # without rewriting history first.
167
+ if [ -f .prism/gitleaks-baseline.json ]; then
168
+ ARGS+=(--baseline-path=.prism/gitleaks-baseline.json)
169
+ echo "Using baseline: .prism/gitleaks-baseline.json"
170
+ fi
171
+ if [ -f .gitleaks.toml ]; then
172
+ echo "Using repo config: .gitleaks.toml"
173
+ fi
174
+
175
+ set +e
176
+ gitleaks "${ARGS[@]}"
177
+ RC=$?
178
+ set -e
179
+
180
+ case "${RC}" in
181
+ 0)
182
+ echo "✅ No secrets detected in ${BASE_SHA}..${HEAD_SHA}"
183
+ echo "leaks=0" >> "$GITHUB_OUTPUT"
184
+ ;;
185
+ 1)
186
+ # Exit 1 means leaks were found. If the count cannot be read the
187
+ # scan still found something, so this must not degrade to 0.
188
+ if ! COUNT=$(jq 'length' "${REPORT}" 2>/dev/null) || [ -z "${COUNT}" ]; then
189
+ echo "::error::gitleaks reported findings but the report could not be parsed -- failing closed."
190
+ echo "error=true" >> "$GITHUB_OUTPUT"
191
+ echo "leaks=-1" >> "$GITHUB_OUTPUT"
192
+ exit 0
193
+ fi
194
+ echo "::error::gitleaks found ${COUNT} secret(s) in this PR's commits"
195
+ # Only safe fields are projected. The secret itself is never
196
+ # echoed, even though --redact already replaced it.
197
+ jq -r '.[] | "::error file=\(.File),line=\(.StartLine)::[\(.RuleID)] secret detected in commit \(.Commit[0:8])"' "${REPORT}" || true
198
+ echo "leaks=${COUNT}" >> "$GITHUB_OUTPUT"
199
+ ;;
200
+ *)
201
+ echo "::error::gitleaks exited ${RC} -- the scan produced no verdict, so this PR is not verified."
202
+ echo "error=true" >> "$GITHUB_OUTPUT"
203
+ echo "leaks=-1" >> "$GITHUB_OUTPUT"
204
+ ;;
205
+ esac
206
+
207
+ # Only installed when the key exists. Without it the review below reports
208
+ # SKIPPED without ever invoking the binary, so installing it would pipe
209
+ # curl into bash on every fork PR for a CLI that is never called. The
210
+ # review step keeps its own HAS_KIRO_KEY check -- this gate is an
211
+ # optimisation, that one is the correctness guarantee.
41
212
  - name: Install Kiro CLI
213
+ if: env.HAS_KIRO_KEY == 'true'
42
214
  run: curl -fsSL https://cli.kiro.dev/install | bash
43
215
 
44
216
  - name: Read team ID
@@ -156,6 +328,9 @@ jobs:
156
328
  REVIEW_MEDIUM: ${{ steps.review.outputs.medium }}
157
329
  REVIEW_RESULT: ${{ steps.review.outputs.result }}
158
330
  REVIEW_SUMMARY: ${{ steps.review.outputs.summary }}
331
+ GITLEAKS_LEAKS: ${{ steps.gitleaks.outputs.leaks }}
332
+ GITLEAKS_ERROR: ${{ steps.gitleaks.outputs.error }}
333
+ GITLEAKS_INSTALL_ERROR: ${{ steps.gitleaks-install.outputs.error }}
159
334
  with:
160
335
  script: |
161
336
  const fs = require('fs');
@@ -199,6 +374,32 @@ jobs:
199
374
  body += `> Powered by kiro-cli headless - Model: claude-haiku-4.5 - Threshold: 0.82`;
200
375
  }
201
376
 
377
+ // Secret scanning is reported for every result path, including the
378
+ // ones where the Kiro review skipped. On a fork PR the review has no
379
+ // API key and reports SKIPPED, so without this the comment would
380
+ // read as "nothing was checked" when in fact the repo WAS scanned
381
+ // for credentials -- the one check that still ran.
382
+ const gl = process.env.GITLEAKS_LEAKS;
383
+ const glErr = process.env.GITLEAKS_ERROR === 'true';
384
+ const glInstallErr = process.env.GITLEAKS_INSTALL_ERROR === 'true';
385
+
386
+ let secretLine;
387
+ if (glInstallErr) {
388
+ secretLine = '❌ **Secret scan** -- gitleaks could not be installed or verified. This PR has **not** been secret-scanned; failing closed.';
389
+ } else if (glErr) {
390
+ secretLine = '❌ **Secret scan** -- gitleaks did not produce a verdict. This PR has **not** been secret-scanned; failing closed.';
391
+ } else if (gl === '0') {
392
+ secretLine = '✅ **Secret scan** -- no credentials found in this PR\'s commits.';
393
+ } else if (gl) {
394
+ secretLine = `❌ **Secret scan** -- gitleaks found **${gl}** secret(s). See the annotations on the Files tab.\n\n` +
395
+ '> Rotate the exposed credential first -- removing it from the branch does not un-expose it. ' +
396
+ 'False positive? Add an allowlist entry to `.gitleaks.toml`.';
397
+ } else {
398
+ secretLine = '⚠️ **Secret scan** -- no result reported.';
399
+ }
400
+
401
+ body += `\n\n---\n\n${secretLine}`;
402
+
202
403
  const { data: comments } = await github.rest.issues.listComments({
203
404
  owner: context.repo.owner, repo: context.repo.repo,
204
405
  issue_number: context.issue.number
@@ -257,6 +458,101 @@ jobs:
257
458
  aws events put-events --region $PRISM_AWS_REGION --entries \
258
459
  "[{\"Source\":\"prism.d1.velocity\",\"DetailType\":\"prism.d1.eval\",\"EventBusName\":\"prism-d1-metrics\",\"Detail\":$(echo "${DETAIL}" | jq -Rs '.')}]" || true
259
460
 
461
+ - name: Emit gitleaks findings to EventBridge
462
+ if: steps.aws-creds.outcome == 'success' && steps.gitleaks.outputs.leaks != '0' && steps.gitleaks.outputs.leaks != '' && steps.gitleaks.outputs.leaks != '-1'
463
+ continue-on-error: true
464
+ env:
465
+ AWS_REGION: ${{ env.PRISM_AWS_REGION }}
466
+ TEAM_ID: ${{ steps.prism-config.outputs.team_id }}
467
+ REPO: ${{ env.PRISM_REPO }}
468
+ PR_NUMBER: ${{ github.event.pull_request.number }}
469
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
470
+ HEAD_SHA: ${{ github.sha }}
471
+ run: |
472
+ # Feeds the Team Velocity / CISO dashboards. Without this the gate
473
+ # blocks the PR but the exposure never reaches the metrics pipeline,
474
+ # so "secrets caught per 100 commits" is unanswerable.
475
+ #
476
+ # continue-on-error, and gated on findings != -1: the gate verdict is
477
+ # decided by the scan step, never by whether telemetry landed. A
478
+ # scan-error PR (findings=-1) has no findings to report.
479
+ REPORT="${RUNNER_TEMP}/gitleaks-report.json"
480
+ if [ ! -s "${REPORT}" ]; then
481
+ echo "No report file — nothing to emit"
482
+ exit 0
483
+ fi
484
+
485
+ # Deferred origin join, same contract as the Continuum step: emit the
486
+ # PR's commit SHAs as facts and let dashboards resolve AI-vs-human at
487
+ # render time. Attribution spans arrive on a ~12h codeburn schedule,
488
+ # so a verdict computed now would usually be wrong and would be baked
489
+ # into an immutable event.
490
+ COMMIT_SHAS=$(git rev-list "${BASE_SHA}..${HEAD_SHA}" 2>/dev/null | head -50 | jq -R . | jq -sc . || echo '[]')
491
+
492
+ # Severity drives real automation: prism.d1.security.code_review also
493
+ # routes to the security-response-automator, which on CRITICAL/HIGH
494
+ # writes a penalty record and fires the SecurityCriticalFinding alarm.
495
+ #
496
+ # So the mapping is deliberately fail-safe rather than uniform.
497
+ # gitleaks' provider-specific rules (aws-access-token, github-pat,
498
+ # private-key, ...) are high-confidence and page as HIGH. Its
499
+ # catch-all entropy rules are not: generic-api-key fired on an
500
+ # `aws_secret_access_key = ...` line in testing, and paging on every
501
+ # one of those trains people to ignore the alarm. Those report MEDIUM
502
+ # — still recorded, still blocking the PR via the gate, just not
503
+ # paging. An unrecognised NEW rule id defaults to HIGH, so the list
504
+ # below only ever downgrades known-noisy rules.
505
+ #
506
+ # CWE-798 = Use of Hard-coded Credentials, correct for every rule here.
507
+ #
508
+ # Fingerprint is gitleaks' own stable id (commit:file:rule:line), so
509
+ # re-running the workflow re-emits the same finding_id instead of
510
+ # inflating counts. Only safe fields are projected -- never .Secret or
511
+ # .Match (both already REDACTED), and never .Message, which is the
512
+ # commit message and could itself contain the credential.
513
+ ENTRIES=$(jq -c \
514
+ --arg team_id "${TEAM_ID}" \
515
+ --arg repo "${REPO}" \
516
+ --argjson commit_shas "${COMMIT_SHAS}" \
517
+ --argjson pr_number "${PR_NUMBER}" \
518
+ --argjson low_confidence '["generic-api-key"]' \
519
+ '[ .[] | . as $f | {
520
+ Source: "prism.d1.velocity",
521
+ DetailType: "prism.d1.security.code_review",
522
+ EventBusName: "prism-d1-metrics",
523
+ Detail: ({
524
+ team_id: $team_id,
525
+ repo: $repo,
526
+ timestamp: (now | todate),
527
+ prism_level: 3,
528
+ metric: { name: "security_finding", value: 1, unit: "count" },
529
+ ai_context: { tool: "gitleaks", model: "gitleaks-8.28.0" },
530
+ security_agent_finding: {
531
+ finding_id: ("gl-" + ($f.Fingerprint // "unknown")),
532
+ phase: "code_review",
533
+ severity: (if ($low_confidence | index($f.RuleID)) then "MEDIUM" else "HIGH" end),
534
+ category: ($f.RuleID // "unknown"),
535
+ title: ($f.Description // $f.RuleID // "Committed secret detected"),
536
+ cwe_id: "CWE-798",
537
+ cvss_score: null,
538
+ exploit_validated: false,
539
+ commit_shas: $commit_shas,
540
+ pr_number: $pr_number,
541
+ found_at: (now | todate)
542
+ }
543
+ } | tostring)
544
+ } ]' "${REPORT}" 2>/dev/null || echo '[]')
545
+
546
+ TOTAL=$(echo "${ENTRIES}" | jq 'length')
547
+ if [ "${TOTAL}" -gt 0 ]; then
548
+ # EventBridge accepts max 10 entries per call
549
+ for i in $(seq 0 10 $((TOTAL - 1))); do
550
+ aws events put-events --region "$PRISM_AWS_REGION" \
551
+ --entries "$(echo "${ENTRIES}" | jq -c ".[$i:$((i+10))]")" || true
552
+ done
553
+ echo "Emitted ${TOTAL} gitleaks finding events to EventBridge"
554
+ fi
555
+
260
556
  - name: AWS Continuum security scan (Tier 2 -- deterministic)
261
557
  id: security-gate
262
558
  if: steps.aws-creds.outcome == 'success'
@@ -298,6 +594,21 @@ jobs:
298
594
  if [ -z "$CODE_REVIEW_ID" ]; then
299
595
  echo "No Code Review found for ${REPO_SLUG} -- bootstrapping..."
300
596
 
597
+ # Continuum rejects a title containing anything outside
598
+ # [A-Za-z0-9_-]. REPO_SLUG keeps the dot from the host component
599
+ # (github.com-owner-repo) because SSM parameter names and S3 keys
600
+ # both accept dots and those paths must stay stable. The title is
601
+ # the only consumer that cannot, so it gets its own sanitised form
602
+ # rather than reshaping the slug everywhere.
603
+ #
604
+ # This broke when CI switched to emitting fully-qualified repo names
605
+ # (github.com/owner/repo) so the attribution keys would match
606
+ # codeburn's. A bare owner/repo slug had no dot and validated fine.
607
+ #
608
+ # printf, not echo: tr -c would otherwise rewrite the trailing
609
+ # newline into a hyphen and leave every title ending in one.
610
+ CR_TITLE=$(printf '%s-ci-diff-scan' "${REPO_SLUG}" | tr -c 'A-Za-z0-9_-' '-' | cut -c1-100)
611
+
301
612
  # Get service role (SSM or convention fallback)
302
613
  SERVICE_ROLE=$(aws ssm get-parameter --name /prism/continuum/service-role-arn --query Parameter.Value --output text 2>/dev/null || echo "")
303
614
  if [ -z "$SERVICE_ROLE" ]; then
@@ -313,11 +624,17 @@ jobs:
313
624
  # Create the Code Review resource
314
625
  CR_OUTPUT=$(aws securityagent create-code-review \
315
626
  --agent-space-id "$AGENT_SPACE_ID" \
316
- --title "${REPO_SLUG}-ci-diff-scan" \
627
+ --title "$CR_TITLE" \
317
628
  --service-role "$SERVICE_ROLE" \
318
629
  --assets "{\"sourceCode\":[{\"s3Location\":\"s3://${SCAN_BUCKET}/repo/${REPO_SLUG}/latest.zip\"}]}" \
319
630
  --output json 2>&1) || true
320
- CODE_REVIEW_ID=$(echo "$CR_OUTPUT" | jq -r '.codeReviewId // empty' 2>/dev/null)
631
+ # `|| echo ""` is load-bearing. CR_OUTPUT captures stderr via 2>&1,
632
+ # so a failed create leaves AWS's error TEXT here, not JSON. jq exits
633
+ # 5 on a parse error, and under bash -eo pipefail that aborts the
634
+ # whole step -- which made the diagnostic below unreachable and
635
+ # surfaced a bare "Process completed with exit code 5" instead of the
636
+ # actual ValidationException.
637
+ CODE_REVIEW_ID=$(echo "$CR_OUTPUT" | jq -r '.codeReviewId // empty' 2>/dev/null || echo "")
321
638
 
322
639
  if [ -z "$CODE_REVIEW_ID" ]; then
323
640
  echo "::error::Could not create the Continuum Code Review resource: ${CR_OUTPUT}"
@@ -470,7 +787,7 @@ jobs:
470
787
  fi
471
788
 
472
789
  - name: Fail on eval gate failure
473
- if: ${{ steps.review.outputs.result == 'FAIL' || steps.review.outputs.result == 'ERROR' || steps.security-gate.outputs.scan_error == 'true' || (steps.security-gate.outputs.skipped != 'true' && (steps.security-gate.outputs.critical != '0' || steps.security-gate.outputs.high != '0')) }}
790
+ if: ${{ steps.gitleaks-install.outputs.error == 'true' || steps.gitleaks.outputs.error == 'true' || (steps.gitleaks.outputs.leaks != '0' && steps.gitleaks.outputs.leaks != '') || steps.review.outputs.result == 'FAIL' || steps.review.outputs.result == 'ERROR' || steps.security-gate.outputs.scan_error == 'true' || (steps.security-gate.outputs.skipped != 'true' && (steps.security-gate.outputs.critical != '0' || steps.security-gate.outputs.high != '0')) }}
474
791
  env:
475
792
  REVIEW_RESULT: ${{ steps.review.outputs.result }}
476
793
  REVIEW_HIGH: ${{ steps.review.outputs.high }}
@@ -479,7 +796,21 @@ jobs:
479
796
  SEC_CRITICAL: ${{ steps.security-gate.outputs.critical }}
480
797
  SEC_HIGH: ${{ steps.security-gate.outputs.high }}
481
798
  SEC_SCAN_ERROR: ${{ steps.security-gate.outputs.scan_error }}
799
+ GITLEAKS_LEAKS: ${{ steps.gitleaks.outputs.leaks }}
800
+ GITLEAKS_ERROR: ${{ steps.gitleaks.outputs.error }}
801
+ GITLEAKS_INSTALL_ERROR: ${{ steps.gitleaks-install.outputs.error }}
482
802
  run: |
803
+ if [ "${GITLEAKS_INSTALL_ERROR}" = "true" ]; then
804
+ echo "❌ Secret scan failed closed: gitleaks could not be installed or verified."
805
+ echo " This PR has NOT been scanned for credentials. Re-run the workflow."
806
+ elif [ "${GITLEAKS_ERROR}" = "true" ]; then
807
+ echo "❌ Secret scan failed closed: gitleaks produced no verdict."
808
+ echo " This PR has NOT been scanned for credentials. Re-run the workflow."
809
+ elif [ -n "${GITLEAKS_LEAKS}" ] && [ "${GITLEAKS_LEAKS}" != "0" ]; then
810
+ echo "❌ gitleaks found ${GITLEAKS_LEAKS} secret(s) in this PR's commits"
811
+ echo " Rotate the credential first -- deleting it from the branch does not un-expose it."
812
+ echo " False positive? Add an allowlist entry to .gitleaks.toml."
813
+ fi
483
814
  if [ "${REVIEW_RESULT}" = "ERROR" ]; then
484
815
  echo "❌ Eval gate failed closed: review output could not be parsed"
485
816
  elif [ "${REVIEW_RESULT}" = "FAIL" ]; then