Changelog
- 1.0.0, 22 October 2026: first release
kits / secure-skills
Secret scanning, dependency audits, CSP rollout and first-hour incident steps, with configs ready to copy into your repo.
MIT License
Copyright (c) 2026 Snowkit
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.Version 1.0.0
Security checklists, configs and scripts for the jobs that sit between "we wrote the code" and "we sleep at night": catching secrets before they're committed, auditing dependencies every week, rolling out a Content Security Policy with real reports, reviewing sign-in and upload code, and knowing exactly what to do in the first hour after a leak. Every config is ready to copy into a repository, and every decision is explained in the Markdown next to it.
It assumes you're comfortable with git, a terminal and your CI system. It goes further than a pre-launch checklist: each area has working files plus the reasoning behind them.
secrets/.gitleaks.toml to your repository root and run gitleaks git --redact . to see what's already in your history.secrets/secret-scan.yml and dependencies/dependency-audit.yml into .github/workflows/, and dependencies/scripts/audit-deps.sh into scripts/.sh scripts/audit-deps.sh locally and look at what it reports.incident/first-hour-checklist.md and fill in the contacts table now, while nothing is on fire.headers/csp-rollout.md and the two checklists in checklists/ on your list for the next sprint.| File | What it is |
|---|---|
README.md | This file |
LICENSE.txt | MIT licence |
CHANGELOG.md | Version history |
secrets/README.md | Secret scanning at three layers: how the config, hook and CI workflow fit together, allowlist rules and baselines |
secrets/.gitleaks.toml | Scanner config with the built-in rules, a custom token rule and an allowlist example |
secrets/pre-commit-setup.md | Setting up the pre-commit check with the pre-commit framework or a plain git hook, including Windows notes and team rollout |
secrets/.pre-commit-config.yaml | Config for the pre-commit framework |
secrets/hooks/pre-commit | Plain git hook that scans staged changes and stops the commit on a finding |
secrets/secret-scan.yml | GitHub Actions workflow: scans pull requests, pushes to main and full history weekly, with a checksum-verified scanner download |
secrets/leaked-secret-response.md | What to do when a real secret is found: rotate first, check for misuse, then purge history |
dependencies/README.md | How the audit script and weekly workflow work, settings, and limits for each ecosystem |
dependencies/scripts/audit-deps.sh | POSIX shell script that runs npm, pnpm and Python audits and exits non-zero on findings at or above a threshold |
dependencies/dependency-audit.yml | GitHub Actions workflow that runs the audit weekly and on dependency changes |
dependencies/update-policy.md | Fill-in policy: response times by severity, reachability, exceptions, routine updates and new dependency checks |
headers/csp-rollout.md | Five-stage CSP rollout: reporting, report-only, triage, nonces and hashes, enforcement, plus a directives table |
headers/csp-report-endpoint/node-server.mjs | Standalone Node.js endpoint that receives and logs CSP violation reports, with no dependencies |
headers/csp-report-endpoint/worker.js | The same endpoint as a Cloudflare-style worker |
headers/headers-checklist.md | Review checklist for live response headers: transport, CSP, CORS, caching, cookies and leaks |
incident/first-hour-checklist.md | The first hour after a suspected breach or leaked key: contain, keep evidence, tell people, rotate keys in order |
incident/evidence-log.md | Fill-in log for actions, evidence, credentials, notifications and open questions |
checklists/auth-and-sessions.md | Passwords, sign-in, MFA, sessions, tokens, authorisation, recovery and OAuth |
checklists/file-uploads.md | Validation, storage, serving, direct-to-storage uploads, processing and tests for upload features |
These files reduce common risks and make your response faster. They can't make a system secure on their own, and they don't replace a professional review or penetration test for high-risk systems. Scanners only find known patterns and known advisories. The legal notes in the incident files are general; get advice for your own situation.
MIT License. See LICENSE.txt.
For apps that handle their own sign-in, or that wrap a hosted auth provider with their own session logic. If you use a hosted provider for everything, most of the password items are its job, but the session, authorisation and recovery items are still yours.
Mark each item done, not applicable, or "risk accepted" with a reason.
Secure, HttpOnly, SameSite=Lax or Strict, and use the __Host- prefix where possible.exp, nbf, iss and aud.alg: none, or a different algorithm, are rejected.localStorage when a cookie would do. Any script running on the page can read localStorage./invoices/1041 to /invoices/1042 must not show someone else's invoice.role, isAdmin, ownerId or price by adding them to a request body.Referrer-Policy: no-referrer so the token doesn't leak to third-party scripts or links.state parameter is used and checked to prevent cross-site request forgery.| Area | Reviewer | Date | Open risks |
|---|---|---|---|
| Passwords and sign-in | |||
| MFA | |||
| Sessions and tokens | |||
| Authorisation | |||
| Recovery and email change |
File uploads let strangers put content on your systems. Each item below closes one way that goes wrong: running code on your server, serving malware from your domain, filling your storage bill, or leaking one user's files to another.
Content-Type the browser sent. Both are set by the client.invoice.pdf.exe, photo.php.jpg) and trailing dots or spaces are rejected..zip and similar) are only accepted if you need them. If you extract them, cap the total extracted size and file count, and reject entries with ../ or absolute paths.example-usercontent.com) or a dedicated subdomain with no cookies, so a malicious file can't run in your main site's origin.X-Content-Type-Options: nosniff and the stored, validated Content-Type.Content-Disposition: attachment.default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox.If the browser uploads straight to object storage with a pre-signed URL or form:
.png). It is rejected.<img src=x onerror=alert(1)>.png. The name displays as text.| Upload feature | Allowed types | Max size | Stored where | Served from | Reviewer | Date |
|---|
Most of the code in a modern app was written by someone else. A dependency audit checks your locked package versions against public vulnerability databases and tells you which ones have known problems. This folder gives you a script that runs the right audit tool for each language it finds, a weekly CI job and a policy for how fast to act.
| File | What it is |
|---|---|
scripts/audit-deps.sh | POSIX shell script that runs npm audit, pnpm audit and pip-audit when the matching lockfile and tool are present, and exits non-zero on findings at or above a threshold |
dependency-audit.yml | GitHub Actions workflow that runs the script every Monday and on pull requests that change dependencies |
update-policy.md | How quickly to fix findings by severity, how to handle ones you can't fix yet, and how to keep routine updates flowing |
scripts/audit-deps.sh to scripts/audit-deps.sh in your repository. sh scripts/audit-deps.sh
dependency-audit.yml to .github/workflows/dependency-audit.yml.update-policy.md with your team and agree the response times.| It finds | It runs | Fails when |
|---|---|---|
package-lock.json or npm-shrinkwrap.json | npm audit --audit-level=<level> | A finding at or above the level |
pnpm-lock.yaml | pnpm audit --audit-level <level> | A finding at or above the level |
requirements.txt, requirements-prod.txt, requirements/base.txt or requirements/prod.txt | pip-audit -r <file> for each | Any known vulnerability |
pyproject.toml (and none of the above) | pip-audit . | Any known vulnerability |
It exits with:
0 when every audit that ran passed1 when any audit found something at or above the threshold, or failed to run2 for a bad argument or settingSet these as environment variables.
| Variable | Default | Meaning |
|---|---|---|
AUDIT_LEVEL | high | Lowest npm and pnpm severity that fails the run: low, moderate, high or critical |
AUDIT_STRICT | 0 | 1 makes a missing tool, or finding nothing to audit, a failure. CI sets this so a broken setup can't pass silently |
AUDIT_PROD_ONLY | 0 | 1 skips npm and pnpm dev dependencies |
Pass a folder as the first argument to audit a sub-project: sh scripts/audit-deps.sh services/api. For a monorepo, call it once per package folder that has its own lockfile.
update-policy.md to decide how fast to act on each one.--strict on pip-audit fails when a package can't be audited at all, for example a private package not on PyPI. If you have private packages, either remove --strict from the script or list them with --ignore-vuln or a private index setting.poetry export -f requirements.txt -o requirements.txt or uv export --format requirements-txt > requirements.txt) so the audit sees exact locked versions.yarn audit --level high, which reports through a bitmask exit code. Yarn Berry uses yarn npm audit --severity high. Add a block for the one you use, in the same pattern as the npm block.AUDIT_PROD_ONLY if you have a reason, and audit dev dependencies separately on a slower schedule.Vulnerabilities are published against versions you already shipped. A pull request check only catches problems in new changes. The weekly run catches new advisories against code that hasn't changed. Pick a time when someone will read the result, and route failures to a channel people watch.
update-policy.md covers how to decide.name: dependency-audit
on:
schedule:
- cron: "41 6 * * 1"
pull_request:
paths:
- "package.json"
- "package-lock.json"
- "npm-shrinkwrap.json"
- "pnpm-lock.yaml"
- "requirements*.txt"
- "requirements/**"
- "pyproject.toml"
- "scripts/audit-deps.sh"
workflow_dispatch:
permissions:
contents: read
jobs:
audit:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Check out
uses: actions/checkout@v4
- name: Set up Node.js
if: hashFiles('package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml') != ''
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Enable pnpm
if: hashFiles('pnpm-lock.yaml') != ''
run: corepack enable pnpm
- name: Set up Python
if: hashFiles('requirements*.txt', 'requirements/**', 'pyproject.toml') != ''
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install pip-audit
if: hashFiles('requirements*.txt', 'requirements/**', 'pyproject.toml') != ''
run: python -m pip install --disable-pip-version-check pip-audit
- name: Audit dependencies
env:
AUDIT_LEVEL: high
AUDIT_STRICT: "1"
AUDIT_PROD_ONLY: "0"
run: sh scripts/audit-deps.sh .Copy this into your repository or team handbook and change the numbers to suit you. A written policy turns "we should update that" into a decision with an owner and a date.
Team: [Team name] Policy owner: [Name] Last reviewed: [Date]
Times start when the finding is first reported to the team (the weekly audit, a bot pull request or an advisory email).
| Severity | Reachable in our app or exposed to the internet | Not reachable, or dev tooling only |
|---|---|---|
| Critical | Fix or mitigate within 2 working days | Fix within 14 days |
| High | Fix or mitigate within 7 days | Fix within 30 days |
| Moderate | Fix within 30 days | Next routine update |
| Low | Next routine update | Next routine update |
"Mitigate" means the risk is removed even if the package isn't updated yet: a feature turned off, an input blocked at the edge, a config change. Record the mitigation in the exception register (section 4).
Treat a finding as critical and reachable regardless of score if:
Spend no more than 15 minutes on this per finding. When unsure, treat it as reachable.
Write the answer in the pull request or ticket, so the next person doesn't redo the work.
overrides in package.json for npm, pnpm.overrides for pnpm, a constraints file for pip. Remove the override once the direct dependency catches up.Some findings can't be fixed in time. Record each one:
| Advisory ID | Package | Severity | Why not fixed | Mitigation | Owner | Review by |
|---|---|---|---|---|---|---|
| [GHSA or CVE ID] | [name@version] | [High] | [No fixed release] | [Feature behind flag, input validated] | [Name] | [Date, at most 30 days out] |
Rules:
pip-audit --ignore-vuln <ID>, npm overrides, or a reviewed allowlist file) so the exception is visible in code, not only in a spreadsheet.Security fixes are much easier when you are only a few versions behind.
Before adding a package, the person adding it checks:
| Task | Owner |
|---|---|
| Watch the weekly audit result | [Name or rotation] |
| Triage new findings within 1 working day | [Name or rotation] |
| Merge routine update pull requests | [Name or rotation] |
| Review exceptions monthly | [Policy owner] |
| Review this policy yearly | [Policy owner] |
#!/bin/sh
set -u
root="${1:-.}"
level="${AUDIT_LEVEL:-high}"
strict="${AUDIT_STRICT:-0}"
prod_only="${AUDIT_PROD_ONLY:-0}"
status=0
ran=0
case "$level" in
low|moderate|high|critical) ;;
*)
echo "audit-deps: AUDIT_LEVEL must be low, moderate, high or critical, not '$level'" >&2
exit 2
;;
esac
if ! cd "$root"; then
echo "audit-deps: cannot open directory '$root'" >&2
exit 2
fi
have() {
command -v "$1" >/dev/null 2>&1
}
missing() {
echo "audit-deps: found $1 but $2 is not installed, skipping" >&2
if [ "$strict" = "1" ]; then
status=1
fi
}
fail() {
echo "audit-deps: $1 reported findings at or above the threshold" >&2
status=1
}
if [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then
if have npm; then
echo "audit-deps: npm audit, threshold $level"
ran=1
if [ "$prod_only" = "1" ]; then
npm audit --audit-level="$level" --omit=dev || fail "npm audit"
else
npm audit --audit-level="$level" || fail "npm audit"
fi
else
missing package-lock.json npm
fi
fi
if [ -f pnpm-lock.yaml ]; then
if have pnpm; then
echo "audit-deps: pnpm audit, threshold $level"
ran=1
if [ "$prod_only" = "1" ]; then
pnpm audit --audit-level "$level" --prod || fail "pnpm audit"
else
pnpm audit --audit-level "$level" || fail "pnpm audit"
fi
else
missing pnpm-lock.yaml pnpm
fi
fi
python_targets=""
for req in requirements.txt requirements-prod.txt requirements/base.txt requirements/prod.txt; do
if [ -f "$req" ]; then
python_targets="$python_targets $req"
fi
done
if [ -n "$python_targets" ]; then
if have pip-audit; then
for req in $python_targets; do
echo "audit-deps: pip-audit on $req"
ran=1
pip-audit --strict --progress-spinner off -r "$req" || fail "pip-audit ($req)"
done
else
missing "$python_targets" pip-audit
fi
elif [ -f pyproject.toml ]; then
if have pip-audit; then
echo "audit-deps: pip-audit on the local project"
ran=1
pip-audit --strict --progress-spinner off . || fail "pip-audit (pyproject.toml)"
else
missing pyproject.toml pip-audit
fi
fi
if [ "$ran" -eq 0 ]; then
echo "audit-deps: no audit was run in $(pwd)"
if [ "$strict" = "1" ]; then
status=1
fi
fi
if [ "$status" -eq 0 ]; then
echo "audit-deps: passed"
else
echo "audit-deps: failed" >&2
fi
exit "$status"A Content Security Policy (CSP) tells the browser which scripts, styles, frames and connections a page may use. A good one turns most cross-site scripting bugs from "attacker runs code in your users' browsers" into "browser blocks it and sends you a report". A careless one breaks your checkout on a Friday evening.
This guide takes you from no policy to an enforced one in five stages, with reports collected the whole way.
You need somewhere for browsers to send violation reports. Use csp-report-endpoint/ in this folder, or a hosted reporting service.
| Option | When to use it |
|---|---|
csp-report-endpoint/worker.js | You use Cloudflare Workers or a similar edge runtime. Deploy it on a route such as https://example.com/csp-report |
csp-report-endpoint/node-server.mjs | You run Node.js (version 18 or later). Run it behind your reverse proxy on the same route |
| A hosted reporting service | You want dashboards and grouping without running anything |
Both endpoint files:
report-uri format and the newer Reporting API (report-to) formatALLOWED_ORIGINSSettings (environment variables for Node, variables or secrets for the worker):
| Variable | Example | Meaning |
|---|---|---|
ALLOWED_ORIGINS | https://example.com,https://www.example.com | Sites allowed to send reports cross-origin. Not needed if the endpoint is on the same origin as the pages |
REPORT_PATH | /csp-report | Path the endpoint listens on |
PORT | 8787 | Node only. Port to listen on |
Run the Node version with node node-server.mjs. It has no dependencies.
Reports can be sent by anyone, so treat the data as untrusted. Add a rate limit at your proxy or edge if the endpoint is public, and don't display report contents in an admin page without escaping them.
Content-Security-Policy-Report-Only makes the browser report violations without blocking anything. Start with a strict policy in this mode so the reports show you everything that would break.
Reporting-Endpoints: csp="https://example.com/csp-report"
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; report-uri https://example.com/csp-report; report-to csp
Include both report-uri and report-to. Browsers that support report-to ignore report-uri; older ones only understand report-uri.
Leave it running for at least a week, or one full release cycle, so you see traffic from every page and every browser your users have.
Group reports by directive and blockedUrl. For each group, decide one of three things:
| Report looks like | Likely cause | Action |
|---|---|---|
script-src with a third-party domain you use (payments, analytics, chat widget) | Expected script | Add that exact host to script-src |
script-src with blockedUrl of inline | Inline <script> or onclick= handler | Move to a file, or use a nonce or hash (stage 4) |
script-src with eval | Code calling eval or new Function | Find the library. Replace it if you can; add 'unsafe-eval' only as a last resort |
style-src with inline | Inline style= attributes or <style> blocks | Nonce or hash for <style> blocks. Many apps accept 'unsafe-inline' in style-src; it is far less dangerous than in script-src |
connect-src with your API or a provider | fetch, XHR or WebSocket call | Add the host. Include wss:// hosts for realtime features |
img-src with blob: or data: | Generated images, previews | Add blob: or data: to img-src only |
frame-src with a payment or video host | Embedded iframe | Add that exact host to frame-src |
| A domain you don't recognise, from a single user | Browser extension or injected malware on that user's machine | Ignore it. Don't loosen the policy for one person's extension |
chrome-extension, moz-extension, safari-web-extension | Browser extension | Ignore it |
Keep a short list of every source you add and why. When a provider is removed from your app, remove its host from the policy too.
Allowing 'unsafe-inline' in script-src removes most of the protection a CSP gives you. There are two clean alternatives.
The server creates a fresh random value for every response, puts it in the header and adds it to each <script> tag it renders. An attacker who injects a script can't guess the value.
Content-Security-Policy: script-src 'nonce-R4nd0mValueFromServer' 'strict-dynamic'; object-src 'none'; base-uri 'none'
<script nonce="R4nd0mValueFromServer" src="/app.js"></script>
Rules for nonces:
crypto.randomBytes(16).toString("base64").'strict-dynamic' lets a trusted (nonced) script load further scripts, which is how most bundlers and tag managers work. With it, host lists in script-src are ignored by modern browsers.A minimal Express example:
import crypto from "node:crypto";
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString("base64");
res.locals.cspNonce = nonce;
res.setHeader(
"Content-Security-Policy",
`script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'`
);
next();
});
Then render nonce="<%= cspNonce %>" (or your template's equivalent) on every script tag. Next.js, Nuxt, SvelteKit, Rails and Django all have documented ways to do this; use your framework's method rather than rolling your own.
For a fixed inline script, put the SHA-256 hash of its exact contents in the policy. Static hosts can't create a nonce per response, so hashes are the usual choice there.
printf '%s' 'document.documentElement.classList.add("js")' | openssl dgst -sha256 -binary | openssl base64
Content-Security-Policy: script-src 'self' 'sha256-<the base64 output>'
The hash covers the exact characters between <script> and </script>, including spaces and line breaks. Any change, even reformatting, needs a new hash. Your browser's console prints the expected hash when it blocks an inline script, which is the quickest way to get it right.
Hashes don't cover onclick= and other inline event handlers. Move those into a script file with addEventListener.
Content-Security-Policy header.report-uri and report-to on the enforced policy, so you hear about breakage straight away.| Directive | Controls | Starter value |
|---|---|---|
default-src | Fallback for most fetch directives not listed | 'self' |
script-src | JavaScript files and inline scripts | 'self', or a nonce with 'strict-dynamic' |
style-src | Stylesheets and inline styles | 'self' (add 'unsafe-inline' only if needed) |
img-src | Images and favicons | 'self' data: |
font-src | Web fonts | 'self' plus your font host |
connect-src | fetch, XHR, WebSocket, EventSource | 'self' plus your API and provider hosts |
frame-src | What your page may embed in iframes | 'none' or exact hosts |
frame-ancestors | Who may embed your page in an iframe | 'none' (replaces X-Frame-Options) |
object-src | Plugins such as <object> and <embed> | 'none' |
base-uri | Allowed values for <base href> | 'self' or 'none' |
form-action | Where forms may submit | 'self' plus your payment host if forms post there |
worker-src | Web workers and service workers | 'self' |
manifest-src | Web app manifest | 'self' |
media-src | Audio and video | 'self' plus your media host |
upgrade-insecure-requests | Rewrites http: subresource URLs to https: | Include it on HTTPS-only sites |
report-uri / report-to | Where violation reports go | Your reporting endpoint |
Notes:
frame-ancestors, report-uri and report-to don't work in a <meta http-equiv> CSP. Send the policy as a header whenever you can.'self' doesn't include subdomains. https://cdn.example.com must be listed separately.https:, * or a whole CDN domain that hosts anyone's files. Each one lets an attacker load code from somewhere they control.Once your CSP is enforced, require-trusted-types-for 'script' makes the browser reject strings passed to dangerous DOM sinks such as innerHTML, unless they go through a policy you define. It blocks most DOM-based cross-site scripting. Browser support is not universal yet, so roll it out in report-only mode first, exactly as above.
| Date | Change | Mode | Who | Result |
|---|---|---|---|---|
| [Date] | Strict starter policy | Report-only | [Name] | [Notes] |
Use this to review the headers a site actually sends, not the ones you think you configured. Hosts, CDNs, frameworks and proxies all add, change and drop headers, so always check the live response.
From a terminal:
curl -sSI https://example.com/
curl -sSI https://example.com/login
curl -sSI https://example.com/api/health
curl -sSI http://example.com/
Check at least: the home page, a logged-in page, an API response, a static asset, an error page (/this-page-does-not-exist) and the plain http:// address. Error pages and API routes are often served by a different layer and miss headers the main pages have.
http:// requests redirect to https:// with a 301 or 308, on the apex domain and www.Strict-Transport-Security is present on HTTPS responses, with max-age of at least 31536000.includeSubDomains is only set once every subdomain works over HTTPS, including old marketing and mail subdomains.preload is only set if you have read the requirements at hstspreload.org and accept that removal takes months.Content-Security-Policy includes frame-ancestors 'none' (or a list of hosts allowed to embed you).X-Frame-Options: DENY or SAMEORIGIN is also set, for older browsers. If both are set, they agree.X-Content-Type-Options: nosniff is on every response, including API and static assets.Referrer-Policy is strict-origin-when-cross-origin or stricter. Use no-referrer on pages whose URLs contain tokens, such as password reset pages.csp-rollout.md).script-src doesn't contain 'unsafe-inline' without a nonce or hash, and doesn't contain *, https: or data:.object-src 'none' and base-uri are set.Cross-Origin-Opener-Policy: same-origin on pages that don't need to talk to popups from other origins. Use same-origin-allow-popups if you open payment or sign-in popups that must message back.Cross-Origin-Resource-Policy: same-origin on private assets and API responses that other sites have no reason to load.Cross-Origin-Embedder-Policy: require-corp only if you need cross-origin isolation (for example SharedArrayBuffer). It breaks third-party embeds that don't opt in, so test first.Permissions-Policy turns off features you don't use: camera=(), microphone=(), geolocation=(), usb=(), payment=() and so on.self or the specific embedded origin that needs them.Access-Control-Allow-Origin is not * on any endpoint that uses cookies or returns private data.example.com.attacker.net, and an "ends with" test accepts attacker-example.com.Access-Control-Allow-Credentials: true only appears with an exact origin, never with a reflected one.Vary: Origin is set when the allowed origin changes per request, so caches don't serve one site's CORS answer to another.OPTIONS) responses don't allow more methods or headers than the API uses.Cache-Control: no-store (or private, no-cache if you need revalidation).Cache-Control: public, max-age=31536000, immutable.Secure, HttpOnly and SameSite=Lax (or Strict).__Host- prefix where possible, which forces Secure, Path=/ and no Domain, so subdomains can't set or read them.Domain attribute wider than it needs.SameSite=None; Secure and nothing else does.Clear-Site-Data: "cookies", "storage" if you want the browser to drop all local state for your origin.X-Powered-By header (Express, PHP and others add it by default).Server header doesn't include version numbers.X-Debug-Token, X-Request-Body and the like)./.well-known/security.txt exists, with a contact address and an Expires date in the future./.env, /.git/config, /config.json and backup files (.bak, .old, ~) return 404, not the file.| URL checked | HSTS | CSP | XFO / frame-ancestors | nosniff | Referrer | COOP | Cache-Control | Date |
|---|---|---|---|---|---|---|---|---|
/ | ||||||||
/login | ||||||||
/api/... | ||||||||
/assets/... | ||||||||
| 404 page |
import { createServer } from "node:http";
const PORT = Number(process.env.PORT || 8787);
const PATH = process.env.REPORT_PATH || "/csp-report";
const ALLOWED_ORIGINS = new Set(
(process.env.ALLOWED_ORIGINS || "")
.split(",")
.map((value) => value.trim())
.filter(Boolean)
);
const MAX_BODY_BYTES = 64 * 1024;
const MAX_FIELD_LENGTH = 512;
const ACCEPTED_TYPES = ["application/csp-report", "application/reports+json", "application/json"];
function trimUrl(value) {
if (typeof value !== "string" || value.length === 0) return undefined;
try {
const url = new URL(value);
return `${url.origin}${url.pathname}`.slice(0, MAX_FIELD_LENGTH);
} catch {
return value.slice(0, MAX_FIELD_LENGTH);
}
}
function trimText(value) {
if (value === undefined || value === null) return undefined;
return String(value).slice(0, MAX_FIELD_LENGTH);
}
function normaliseReports(payload) {
const items = Array.isArray(payload) ? payload : [payload];
const reports = [];
for (const item of items) {
if (item && typeof item === "object" && item["csp-report"]) {
const r = item["csp-report"];
reports.push({
format: "report-uri",
documentUrl: trimUrl(r["document-uri"]),
blockedUrl: trimUrl(r["blocked-uri"]) || trimText(r["blocked-uri"]),
directive: trimText(r["effective-directive"] || r["violated-directive"]),
disposition: trimText(r.disposition),
sourceFile: trimUrl(r["source-file"]),
line: Number(r["line-number"]) || undefined,
column: Number(r["column-number"]) || undefined,
sample: trimText(r["script-sample"]),
});
} else if (item && typeof item === "object" && item.type === "csp-violation" && item.body) {
const b = item.body;
reports.push({
format: "report-to",
documentUrl: trimUrl(b.documentURL),
blockedUrl: trimUrl(b.blockedURL) || trimText(b.blockedURL),
directive: trimText(b.effectiveDirective),
disposition: trimText(b.disposition),
sourceFile: trimUrl(b.sourceFile),
line: Number(b.lineNumber) || undefined,
column: Number(b.columnNumber) || undefined,
sample: trimText(b.sample),
});
}
}
return reports;
}
function corsHeaders(origin) {
if (!origin || !ALLOWED_ORIGINS.has(origin)) return {};
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Max-Age": "86400",
Vary: "Origin",
};
}
function send(res, status, headers = {}) {
res.writeHead(status, { "Cache-Control": "no-store", ...headers });
res.end();
}
const server = createServer((req, res) => {
const url = new URL(req.url || "/", "http://localhost");
const cors = corsHeaders(req.headers.origin);
if (url.pathname !== PATH) return send(res, 404);
if (req.method === "OPTIONS") return send(res, 204, cors);
if (req.method !== "POST") return send(res, 405, { Allow: "POST, OPTIONS" });
const type = String(req.headers["content-type"] || "").split(";")[0].trim().toLowerCase();
if (!ACCEPTED_TYPES.includes(type)) return send(res, 415, cors);
const declared = Number(req.headers["content-length"] || 0);
if (declared > MAX_BODY_BYTES) return send(res, 413, cors);
let size = 0;
const chunks = [];
let aborted = false;
req.on("data", (chunk) => {
if (aborted) return;
size += chunk.length;
if (size > MAX_BODY_BYTES) {
aborted = true;
send(res, 413, cors);
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => {
if (aborted) return;
let payload;
try {
payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
return send(res, 400, cors);
}
const receivedAt = new Date().toISOString();
for (const report of normaliseReports(payload)) {
process.stdout.write(`${JSON.stringify({ receivedAt, kind: "csp-violation", ...report })}\n`);
}
send(res, 204, cors);
});
req.on("error", () => {
if (!res.headersSent) send(res, 400, cors);
});
});
server.requestTimeout = 10000;
server.headersTimeout = 5000;
server.listen(PORT, () => {
process.stdout.write(`${JSON.stringify({ kind: "startup", port: PORT, path: PATH })}\n`);
});const MAX_BODY_BYTES = 64 * 1024;
const MAX_FIELD_LENGTH = 512;
const ACCEPTED_TYPES = ["application/csp-report", "application/reports+json", "application/json"];
function trimUrl(value) {
if (typeof value !== "string" || value.length === 0) return undefined;
try {
const url = new URL(value);
return `${url.origin}${url.pathname}`.slice(0, MAX_FIELD_LENGTH);
} catch {
return value.slice(0, MAX_FIELD_LENGTH);
}
}
function trimText(value) {
if (value === undefined || value === null) return undefined;
return String(value).slice(0, MAX_FIELD_LENGTH);
}
function normaliseReports(payload) {
const items = Array.isArray(payload) ? payload : [payload];
const reports = [];
for (const item of items) {
if (item && typeof item === "object" && item["csp-report"]) {
const r = item["csp-report"];
reports.push({
format: "report-uri",
documentUrl: trimUrl(r["document-uri"]),
blockedUrl: trimUrl(r["blocked-uri"]) || trimText(r["blocked-uri"]),
directive: trimText(r["effective-directive"] || r["violated-directive"]),
disposition: trimText(r.disposition),
sourceFile: trimUrl(r["source-file"]),
line: Number(r["line-number"]) || undefined,
sample: trimText(r["script-sample"]),
});
} else if (item && typeof item === "object" && item.type === "csp-violation" && item.body) {
const b = item.body;
reports.push({
format: "report-to",
documentUrl: trimUrl(b.documentURL),
blockedUrl: trimUrl(b.blockedURL) || trimText(b.blockedURL),
directive: trimText(b.effectiveDirective),
disposition: trimText(b.disposition),
sourceFile: trimUrl(b.sourceFile),
line: Number(b.lineNumber) || undefined,
sample: trimText(b.sample),
});
}
}
return reports;
}
function corsHeaders(request, env) {
const origin = request.headers.get("Origin");
const allowed = String(env.ALLOWED_ORIGINS || "")
.split(",")
.map((value) => value.trim())
.filter(Boolean);
if (!origin || !allowed.includes(origin)) return {};
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Max-Age": "86400",
Vary: "Origin",
};
}
function reply(status, headers = {}) {
return new Response(null, { status, headers: { "Cache-Control": "no-store", ...headers } });
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
const path = env.REPORT_PATH || "/csp-report";
const cors = corsHeaders(request, env);
if (url.pathname !== path) return reply(404);
if (request.method === "OPTIONS") return reply(204, cors);
if (request.method !== "POST") return reply(405, { Allow: "POST, OPTIONS" });
const type = (request.headers.get("Content-Type") || "").split(";")[0].trim().toLowerCase();
if (!ACCEPTED_TYPES.includes(type)) return reply(415, cors);
const declared = Number(request.headers.get("Content-Length") || 0);
if (declared > MAX_BODY_BYTES) return reply(413, cors);
const body = await request.text();
if (body.length > MAX_BODY_BYTES) return reply(413, cors);
let payload;
try {
payload = JSON.parse(body);
} catch {
return reply(400, cors);
}
const receivedAt = new Date().toISOString();
const country = request.cf && request.cf.country ? request.cf.country : undefined;
for (const report of normaliseReports(payload)) {
console.log(JSON.stringify({ receivedAt, kind: "csp-violation", country, ...report }));
}
return reply(204, cors);
},
};Keep one copy of this per incident. Write as you go, in UTC, in short factual lines. Record what you saw and did, not what you think it means; put interpretations in the "Notes" column.
Store it somewhere the attacker can't edit, and limit who can read it. It may become evidence for insurers, regulators or a court.
Incident ID: [ID] Opened (UTC): [time] Lead: [name]
| Time (UTC) | Who | Action or observation | System | Notes |
|---|---|---|---|---|
| [2026-10-22 14:05] | [Name] | [Alert received: unusual API usage on key ending 9f2a] | [Payments] | [Source: provider email] |
| Item | Source | Collected by | Time (UTC) | Stored at | Hash (SHA-256) |
|---|---|---|---|---|---|
| [Cloud audit log export, 15 to 22 Oct] | [Cloud console] | [Name] | [Evidence folder path] | ||
| [Database snapshot] | [Database provider] | [Name] | [Snapshot ID] | ||
| [Original alert email with headers] | [Mail client] | [Name] |
Hashing each exported file when you collect it lets you show later that it wasn't changed. On macOS and Linux: shasum -a 256 <file>. On Windows: Get-FileHash <file> -Algorithm SHA256.
| Credential | Exposed how | Revoked (UTC) | Replaced by | Updated in | Who |
|---|
| Account | Action taken | Time (UTC) | Who | Reason |
|---|---|---|---|---|
| Suspended / sessions ended / MFA reset |
Record before removing.
| What | Where | Identifier | Created (UTC) | Removed (UTC) | Who |
|---|---|---|---|---|---|
| [New IAM user] | [Cloud account] | [Name or ID] |
| Who was told | By whom | Time (UTC) | Method | Summary of what was said |
|---|
| Question | Owner | Due | Answer and evidence |
|---|---|---|---|
| How did they get in? | |||
| What did they access? | |||
| Do they still have access? |
Use this when you think someone has accessed something they shouldn't, or a credential with real power has been exposed. Print it, or keep it somewhere you can open when your main systems might not be trustworthy.
The first hour has three goals, in this order:
Fixing the root cause and writing the full report come later.
Incident started (UTC): [time] Noticed by: [name] How noticed: [alert, user report, provider email, scanner]
Incident lead: [name] Notes kept by: [name] Notes location: [document or channel]
---
evidence-log.md). Record every action with a UTC time.Contain first, investigate second. But contain in a way that keeps evidence (next section).
Don't:
Logs expire, and some providers keep them for only a few days. Export first, read later.
Tell people early with what you know, and say clearly what you don't know yet.
| Who | When | What they need |
|---|---|---|
| Your manager or founder | Straight away | What happened, what you've done, what you need |
| Security lead, if you have one | Straight away | Full details and access to the log |
| Engineering lead for the affected system | Straight away | What to stop doing, what to check |
| Legal or privacy contact | Within the hour if personal data may be involved | Facts so far, so they can start the clock on notification duties |
| Your cloud or SaaS provider's security team | When you see misuse on their platform | Account ID, times, what you observed |
| Insurer, if you have cyber cover | Early. Policies often require prompt notice and may provide a response firm | Policy number, summary |
| Customer support | Before customers start asking | A holding line to use, and who to escalate to |
| Customers and regulators | After legal advice, unless a deadline forces earlier action | Handled by your legal or privacy contact |
Data protection laws can set short deadlines. Under the GDPR, for example, a reportable personal data breach must be notified to the supervisory authority within 72 hours of becoming aware of it. Other regions and sectors have their own rules. This kit can't tell you what applies to you; that is why the legal or privacy contact is in the first hour.
Rotate in order of power: the credentials that could be used to create new access or undo your other rotations go first.
For each one:
These usually take hours to days. Write each answer with the evidence behind it.
| Role | Name | Phone | Backup | |
|---|---|---|---|---|
| Incident lead | ||||
| Security | ||||
| Engineering lead | ||||
| Legal or privacy | ||||
| Communications | ||||
| Cloud provider support | ||||
| Insurer | ||||
| Incident response firm |
title = "Example Co secret scanning config"
[extend]
useDefault = true
[[rules]]
id = "example-co-api-token"
description = "Example Co internal API token"
regex = '''\bexco_(?:live|test)_[A-Za-z0-9]{32}\b'''
keywords = ["exco_live_", "exco_test_"]
entropy = 3.0
[[allowlists]]
description = "Known safe files and placeholder values"
paths = [
'''(?:^|/)\.env\.example$''',
'''(?:^|/)\.env\.sample$''',
'''(?:^|/)(?:test|tests|__tests__|spec)/fixtures/''',
'''(?:^|/)package-lock\.json$''',
'''(?:^|/)pnpm-lock\.yaml$''',
'''(?:^|/)yarn\.lock$''',
'''(?:^|/)poetry\.lock$''',
'''\.(?:png|jpe?g|gif|webp|ico|pdf|woff2?|ttf)$''',
]
regexes = [
'''(?i)^(?:your|example|sample)[_-]?(?:api[_-]?)?(?:key|token|secret)$''',
'''^(?:sk|pk|rk)_test_0+$''',
'''^exco_test_0{32}$''',
]
stopwords = [
"placeholder",
"changeme",
"dummy",
"redacted",
]repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.24.0
hooks:
- id: gitleaksA secret scanner reads your code and git history and flags anything that looks like a key, token, password or private key. This folder sets one up at three layers, so a leak has to get past all of them:
The files use gitleaks, an open-source scanner (MIT licence) that runs as a single binary on Windows, macOS and Linux. The same ideas apply to other scanners if your team already uses one.
| File | What it is |
|---|---|
.gitleaks.toml | Config that keeps the built-in rules, adds one custom rule and shows how to allowlist known-safe values |
pre-commit-setup.md | Two ways to run the scanner before each commit, and how to roll it out to a team |
.pre-commit-config.yaml | Config for the pre-commit framework |
hooks/pre-commit | A plain git hook for teams that don't use the pre-commit framework |
secret-scan.yml | GitHub Actions workflow that scans pull requests, pushes to main and the full history once a week |
leaked-secret-response.md | What to do when the scanner, or anyone else, finds a real secret |
brew install gitleaks, scoop install gitleaks, winget install gitleaks) or download a release from the gitleaks GitHub releases page..gitleaks.toml to the root of your repository. gitleaks picks it up automatically from there. gitleaks git --redact --verbose .
Deal with every finding before you go further. A finding in old history is still a live secret until it is rotated. Follow leaked-secret-response.md.
pre-commit-setup.md) and copy secret-scan.yml to .github/workflows/secret-scan.yml.Older gitleaks releases use gitleaks detect instead of gitleaks git, and gitleaks protect --staged instead of gitleaks git --pre-commit --staged. If a command in this kit says "unknown command", upgrade gitleaks rather than changing the files.
.gitleaks.toml has three parts.
[extend] useDefault = true keeps the built-in rule set. It covers the common cloud providers, payment platforms, chat tools, private keys and generic high-entropy tokens. Don't remove this line unless you plan to maintain every rule yourself.
[[rules]] adds a rule for a token format that only your company uses. The example matches tokens shaped like exco_live_ followed by 32 letters and digits. Change the id, description, regex and keywords to match your own format. keywords is a fast pre-filter: gitleaks only runs the regex on lines that contain one of them, so keep at least one short, distinctive keyword.
[[allowlists]] tells gitleaks what to ignore. The example shows the four kinds of entry:
| Field | What it ignores | Example use |
|---|---|---|
paths | Whole files, matched by regex against the path | .env.example, test fixtures, lockfiles |
regexes | Findings whose matched secret fits the regex | Obvious placeholders such as EXAMPLE_KEY or a key made of zeros |
stopwords | Findings whose matched secret contains the word | placeholder, changeme, dummy |
commits | Every finding in a given commit | A commit that added a secret which has since been rotated |
Older gitleaks releases only understand a single [allowlist] table. If yours rejects [[allowlists]], upgrade, or rename the header to [allowlist] and keep the same fields.
secret-scan.yml runs in three situations:
| Trigger | What it scans | Why |
|---|---|---|
| Pull request | Only the commits in the pull request | Fast feedback, and a finding blocks the merge |
Push to main | Full history | Catches anything merged without a pull request |
| Weekly schedule (Monday 05:23 UTC) | Full history | Picks up new built-in rules after you upgrade gitleaks |
Notes on the choices:
GITLEAKS_VERSION when you upgrade locally, so CI and laptops use the same rules.--redact keeps secrets out of the CI log. Without it, the scanner would print the very secret it found into a log that many people can read.permissions: contents: read gives the job the least access it needs.gitleaks job as a required status check in your branch protection rules.main, change the branches line.The allowlist is where scanners quietly stop working. Keep it small and reviewed.
commits entry only after the secret in that commit has been rotated and the old one revoked..gitleaks.toml in pull requests like any security change. Consider a CODEOWNERS entry for it.gitleaks also skips a line that ends with the marker gitleaks:allow inside a comment in that file. It is quick, but it hides the decision inside code where nobody reviews it. Prefer an allowlist entry, which keeps every exception in one file.
If a large, old repository has many findings you have already dealt with, you can record them in a baseline and only fail on new ones:
gitleaks git --report-path gitleaks-baseline.json .
gitleaks git --baseline-path gitleaks-baseline.json --redact .
Only do this after every finding in the baseline has been rotated or confirmed as a false positive. A baseline of unreviewed findings is a list of live keys that you have agreed to stop looking at.
Host scanning often knows about partner token formats and can alert the provider, who may revoke the key for you. It doesn't replace local scanning: by the time it runs, the secret has already left your machine.
A secret scanner, a colleague, a provider email or a stranger has told you a key is in your repository. The order of the steps matters: rotate first, then clean up history. Rewriting history first feels productive, but the key keeps working the whole time, and copies already exist in clones, forks, CI caches and scanners run by people who are not on your side.
Assume the secret has been seen if it was ever pushed to a remote, even a private one, even for a minute.
Spend two minutes, not twenty.
If it is a false positive, add a narrow allowlist entry (see README.md) and stop here.
Some secrets can't be swapped instantly without downtime, such as a database password used by a fleet of servers. In that case add a second credential, move traffic to it, then remove the first. Don't leave the old one active "until the weekend".
Special cases:
| Secret type | Rotation note |
|---|---|
| Session or JWT signing secret | Rotating it logs every user out. Do it anyway if the secret was public, because anyone holding it can forge sessions |
| Webhook signing secret | Update the secret in the provider and your handler at the same time, or webhooks fail verification in between |
| SSH or deploy key | Remove the public key from every server and git host it was added to, not only the one you know about |
| Cloud access key | Also check for new users, keys, roles or compute instances the attacker may have created to keep access |
| Personal access token | Revoke it from the owner's account settings; tokens often have wider scope than anyone remembers |
| Private key for TLS or code signing | Revoke the certificate with the issuer and issue a new one |
If you find misuse, stop here and switch to ../incident/first-hour-checklist.md. This is now an incident, not a clean-up.
.gitignore if the secret lived in a config file.Rewriting history is disruptive. Do it when the repository is public, or when the secret can't be rotated (rare), or when your policy requires it. For a private repository with a rotated key, many teams skip this step and record the decision.
The usual tool is git filter-repo. Work on a fresh mirror clone, not your working copy:
git clone --mirror https://github.com/example-co/example-repo.git
cd example-repo.git
printf '%s\n' 'exco_live_REPLACE_WITH_THE_LEAKED_VALUE==>REMOVED' > ../replacements.txt
git filter-repo --replace-text ../replacements.txt
git push --force --mirror
To remove a whole file from every commit instead:
git filter-repo --invert-paths --path config/production.json
After the push:
replacements.txt. It contains the secret..gitignore, a secret in a test, a value copied from a dashboard into code.| Time (UTC) | Action | Who |
|---|---|---|
| Finding reported | ||
| Confirmed real | ||
| New credential created | ||
| Old credential revoked | ||
| Logs reviewed, misuse found: yes / no | ||
| Code fixed | ||
| History purged or decision recorded | ||
| Cause fixed |
A pre-commit hook runs on the developer's machine every time they run git commit. If it finds a possible secret in the staged changes, the commit is stopped before it exists. This is the cheapest place to catch a leak: nothing has been pushed, so nothing needs rotating.
There are two ways to set it up. Pick one per repository.
Use this if your team already uses the pre-commit framework, or works across several languages.
pipx install pre-commit (or pip install pre-commit, or brew install pre-commit)..pre-commit-config.yaml to the root of your repository. If you already have one, add the gitleaks entry to its repos list. pre-commit autoupdate --repo https://github.com/gitleaks/gitleaks
pre-commit install
pre-commit run gitleaks --all-files
The framework downloads and builds gitleaks for you, so developers don't need to install it separately. The first run takes a little longer while it does this.
Use this if you don't want another tool. Each developer needs gitleaks installed.
hooks/pre-commit into your repository, for example as .githooks/pre-commit. git update-index --chmod=+x .githooks/pre-commit
On macOS and Linux, also run chmod +x .githooks/pre-commit in your clone.
git config core.hooksPath .githooks
echo "token = exco_live_Ab12Cd34Ef56Gh78Ij90Kl12Mn34Op56" > leak-test.txt
git add leak-test.txt
git commit -m "test hook"
The commit should be stopped. Then remove the test file: git rm --cached leak-test.txt and delete it.
The hook stops the commit if gitleaks is missing, rather than silently skipping the scan. If you would rather warn and continue, change exit 1 in the first block to exit 0, but then the hook only protects people who remembered to install the scanner.
#!/bin/sh hook works without WSL..gitattributes so git keeps it right: .githooks/* text eol=lf
scoop install gitleaks or winget install gitleaks, and open a new terminal so it is on your PATH.pre-commit install or git config core.hooksPath .githooks).secret-scan.yml) on as well. Hooks are optional by nature: anyone can skip them with git commit --no-verify, and new clones don't have them until someone runs the setup step. CI is the layer that can't be skipped.--redact the secret itself is hidden, but the file, line and rule are shown..gitleaks.toml (a specific value pattern or a single file path) and commit that change with the rest.gitleaks only scans staged changes in the hook, so it is usually well under a second. If it gets slow, check that large generated files and binary assets are covered by paths in the allowlist.
name: secret-scan
on:
pull_request:
push:
branches: [main]
schedule:
- cron: "23 5 * * 1"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: secret-scan-${{ github.ref }}
cancel-in-progress: true
jobs:
gitleaks:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
GITLEAKS_VERSION: "8.24.0"
steps:
- name: Check out full history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install gitleaks
run: |
set -eu
base="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}"
archive="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
curl -sSfL -o "$archive" "$base/$archive"
curl -sSfL -o checksums.txt "$base/gitleaks_${GITLEAKS_VERSION}_checksums.txt"
grep " $archive\$" checksums.txt | sha256sum -c -
tar -xzf "$archive" gitleaks
sudo install -m 0755 gitleaks /usr/local/bin/gitleaks
rm -f "$archive" checksums.txt gitleaks
gitleaks version
- name: Scan pull request commits
if: github.event_name == 'pull_request'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: gitleaks git --redact --verbose --no-banner --log-opts="--no-merges ${BASE_SHA}..${HEAD_SHA}" .
- name: Scan full history
if: github.event_name != 'pull_request'
run: gitleaks git --redact --verbose --no-banner .#!/bin/sh
set -eu
if ! command -v gitleaks >/dev/null 2>&1; then
echo "pre-commit: gitleaks is not installed, so staged changes were not scanned." >&2
echo "pre-commit: install it from your package manager or the gitleaks releases page, then commit again." >&2
exit 1
fi
if ! gitleaks git --pre-commit --staged --redact --no-banner --verbose .; then
echo "" >&2
echo "pre-commit: a possible secret was found in your staged changes. The commit was stopped." >&2
echo "pre-commit: remove it and keep it in an environment variable instead." >&2
echo "pre-commit: if it is a false positive, add an allowlist entry to .gitleaks.toml and get it reviewed." >&2
exit 1
fi