feat(115): secret-scan exclusion governance + --strict reduced-scan mode (#134)

* test(115): add failing tests for secret-scan exclusion lint + strict mode

Adds tests/secret-scan-lint.test.cjs covering all 7 acceptance criteria
for issue #115 (secret-scan exclusion governance):

  1. Lint exits 0 on fully-annotated .secretscanignore fixture
  2. Lint exits 1 on fixture missing required key (reason/owner/expires)
  3. Lint exits 1 on fixture with expires date in the past
  4. Lint exits 1 on wildcard pattern without rule-id
  5. Lint exits 0 on grandfathered entry (default mode), exits 1 under --strict
  6. secret-scan --strict does not honour grandfathered exclusions
     (temp workspace fixture: file with real AWS-key pattern excluded by a
     grandfathered entry → default exits 0, strict exits 1)
  7. secret-scan default mode behaviour unchanged for existing .secretscanignore
     entries (regression test)

All 24 tests confirmed RED on origin/main before any implementation.
Test helpers use spawnSync throughout so both stdout and stderr are always
captured regardless of exit code (fixes the execFileSync/stderr gap from
the existing security-scan.test.cjs pattern).

Design references cited in test file:
  - GitGuardian exclusion annotation convention:
    https://docs.gitguardian.com/internal-repositories-monitoring/integrations/cli/secrets
  - CNCF Security TAG threat-model exception lifecycle:
    https://github.com/cncf/tag-security/blob/main/community/working-groups/threat-modeling/templates/threats.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(115): add secret-scan-lint.sh + --strict mode + annotation parser

Implements secret-scan exclusion governance for issue #115.

## secret-scan-lint.sh (new script)

Exit codes (match secret-scan.sh convention):
  0 = all exclusions valid (or grandfathered with warning)
  1 = annotation violation: missing key, expired date, wildcard without rule-id,
      or (under --strict) any grandfathered entry
  2 = config error (file not found, bad args)

Annotation format (sidecar comment, immediately preceding the path):
  # allow: <pattern>  reason="..."  owner="..."  expires="YYYY-MM-DD"  [rule-id="..."]
  <pattern>

Required keys: reason, owner, expires
Optional key:  rule-id — required when pattern contains * wildcards

Grandfathered entries (plain comment, no structured keys):
  - Default mode: exit 0 + deprecation warning to stderr
  - --strict mode: exit 1

## secret-scan.sh (modified: --strict flag)

--strict flag for release/security-review CI lanes:
  - Grandfathered entries are NOT applied (file is scanned, not skipped)
  - Exclusions whose expires date is past are NOT applied
  - Default mode behaviour is fully preserved

load_ignorelist() now parses annotations:
  - Reads prev_comment to determine annotation status per entry
  - Uses date comparison (YYYY-MM-DD lexicographic) for expires checks
  - Emits DEPRECATION WARNING to stderr for grandfathered entries in default mode
  - Emits WARNING under --strict when skipping a grandfathered entry

Design references:
  - GitGuardian exclusion annotation convention:
    https://docs.gitguardian.com/internal-repositories-monitoring/integrations/cli/secrets
  - CNCF Security TAG threat-model exception lifecycle:
    https://github.com/cncf/tag-security/blob/main/community/working-groups/threat-modeling/templates/threats.md
  - TruffleHog / GitLeaks wildcard-exclusion risk informed the rule-id requirement
    for wildcard entries (unguarded wildcards can accidentally suppress real findings)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(115): annotate existing .secretscanignore entries + wire CI lint step

## .secretscanignore migration

Existing entry `get-shit-done/workflows/plan-phase.md` has been migrated
from a bare plain comment to a fully-structured annotation:

  # allow: get-shit-done/workflows/plan-phase.md
  #   reason="contains illustrative DATABASE_URL/REDIS_URL example strings
  #           used as documentation placeholders — not real credentials"
  #   owner="@open-gsd/maintainers"
  #   expires="2027-06-30"

This entry now passes lint (exit 0) in both default and --strict modes.
The expiration date of 2027-06-30 gives the team ~13 months to review
whether the file still needs to be excluded before the entry expires.

## CI workflow change (.github/workflows/security-scan.yml)

Added step "Secret scan exclusion lint" immediately before the existing
"Planning directory check" step:

  - name: Secret scan exclusion lint
    run: |
      chmod +x scripts/secret-scan-lint.sh
      scripts/secret-scan-lint.sh --file .secretscanignore

The step has no ${{ }} context interpolation in its run block (no
injection surface). It runs on every PR targeting main, release/**, hotfix/**.

This implements CI acceptance criterion from issue #115:
"CI lint fails for unmanaged wildcard exclusions"
"CI enforces policy format"

## Header added to .secretscanignore

Added governance documentation block explaining annotation format,
required/optional keys, and references to design sources:
  - GitGuardian exclusion annotation convention:
    https://docs.gitguardian.com/internal-repositories-monitoring/integrations/cli/secrets
  - CNCF Security TAG threat-model exception lifecycle:
    https://github.com/cncf/tag-security/blob/main/community/working-groups/threat-modeling/templates/threats.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(115): document exclusion governance + periodic reduced-scan procedure

Updates SECURITY.md with a new section "Secret-Scan Exclusion Governance"
covering:

  1. Annotation format (required/optional keys, wildcard rule)
  2. Local lint command
  3. Periodic reduced-exclusion scan procedure using --strict mode

The procedure section explicitly states when to run (every release +
scheduled security review), what --strict does differently, and what to do
when --strict finds findings that default mode does not.

No runbooks/security-audit*.md exists in this repo. SECURITY.md is the
correct location as it is what secret-scan.sh references in its header
docstring (via the "See SECURITY.md" note pattern common in this codebase).

References cited:
  - GitGuardian exclusion annotation convention:
    https://docs.gitguardian.com/internal-repositories-monitoring/integrations/cli/secrets
  - CNCF Security TAG threat-model exception lifecycle:
    https://github.com/cncf/tag-security/blob/main/community/working-groups/threat-modeling/templates/threats.md

Closes #115 (together with feat and chore commits on this branch)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#115): exclude scanner's own test fixtures from diff-mode scan

Add */secret-scan-lint.test.cjs to should_skip_file(), consistent with
the existing exclusions for security-scan.test.cjs and
security-prompt-injection.test.cjs. The test fixture at line 465
contains a DATABASE_URL credential-shaped string that exercises the
Env Variable Leak detector — scanning it as live code is a false positive.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-23 10:58:14 -04:00
committed by GitHub
parent 38d44a6ff8
commit 75287effb9
6 changed files with 906 additions and 14 deletions

View File

@@ -43,6 +43,11 @@ jobs:
chmod +x scripts/secret-scan.sh
scripts/secret-scan.sh --diff "origin/$BASE_REF"
- name: Secret scan exclusion lint
run: |
chmod +x scripts/secret-scan-lint.sh
scripts/secret-scan-lint.sh --file .secretscanignore
- name: Planning directory check
env:
BASE_REF: ${{ github.base_ref }}

View File

@@ -3,9 +3,25 @@
# Glob patterns (one per line) for files that should be skipped.
# Comments (#) and empty lines are ignored.
#
# Examples:
# tests/fixtures/fake-credentials.json
# docs/examples/sample-config.yml
# ANNOTATION FORMAT (required for --strict compliance):
# # allow: <pattern> reason="..." owner="..." expires="YYYY-MM-DD" [rule-id="..."]
# <pattern>
#
# Required keys : reason, owner, expires
# Optional keys : rule-id (REQUIRED when pattern contains * wildcards)
#
# Grandfathered entries (plain comment, no structured annotation) are accepted
# in default mode with a deprecation warning, but fail under --strict mode.
# --strict is used for release and security-review CI lanes.
#
# Governance references:
# - GitGuardian exclusion annotation convention:
# https://docs.gitguardian.com/internal-repositories-monitoring/integrations/cli/secrets
# - CNCF Security TAG threat-model exception lifecycle:
# https://github.com/cncf/tag-security/blob/main/community/working-groups/threat-modeling/templates/threats.md
#
# Lint: scripts/secret-scan-lint.sh --file .secretscanignore
# Strict scan: scripts/secret-scan.sh --diff origin/main --strict
# plan-phase.md contains illustrative DATABASE_URL/REDIS_URL examples
# allow: get-shit-done/workflows/plan-phase.md reason="contains illustrative DATABASE_URL/REDIS_URL example strings used as documentation placeholders — not real credentials" owner="@open-gsd/maintainers" expires="2027-06-30"
get-shit-done/workflows/plan-phase.md

View File

@@ -39,3 +39,37 @@ org-wide security posture — scanner controls, incident-audit checklists,
ownership model, and rollout plan — see:
[`docs/security/baseline.md`](docs/security/baseline.md)
## Secret-Scan Exclusion Governance
Secret-scanning exclusions (`.secretscanignore`) require structured annotations. Bare paths are accepted in default mode with a deprecation warning but are rejected in strict mode. The lint runs on every PR.
### Annotation format
```
# allow: <pattern> reason="..." owner="..." expires="YYYY-MM-DD" [rule-id="..."]
<pattern>
```
Required keys: `reason`, `owner`, `expires`. Wildcard patterns (`**`, `*.ext`) also require `rule-id`.
Lint locally: `scripts/secret-scan-lint.sh --file .secretscanignore`
### Periodic reduced-exclusion scan (release and security-review lanes)
Run this during every release and scheduled security review:
```bash
scripts/secret-scan.sh --diff origin/main --strict
```
The `--strict` flag:
- Does **not** honour grandfathered (un-annotated) exclusions — those files are scanned.
- Skips any exclusion whose `expires` date is in the past — those files are scanned.
- Is intended to surface accumulated exclusion debt that default mode masks.
If `--strict` finds findings that default mode does not, those findings represent either (a) an entry that should have been annotated and renewed, or (b) an actual secret that was only hidden by a stale exclusion. In both cases: investigate, remediate, and update the exclusion annotation.
References:
- GitGuardian exclusion annotation convention: https://docs.gitguardian.com/internal-repositories-monitoring/integrations/cli/secrets
- CNCF Security TAG threat-model exception lifecycle: https://github.com/cncf/tag-security/blob/main/community/working-groups/threat-modeling/templates/threats.md

231
scripts/secret-scan-lint.sh Executable file
View File

@@ -0,0 +1,231 @@
#!/usr/bin/env bash
# secret-scan-lint.sh — Lint governance policy for .secretscanignore exclusions
#
# Usage:
# scripts/secret-scan-lint.sh --file <path-to-.secretscanignore>
# scripts/secret-scan-lint.sh --file <path-to-.secretscanignore> --strict
#
# Exit codes:
# 0 = every exclusion has full annotation OR is grandfathered (with deprecation warning)
# 1 = annotation violation: missing required key, expired date, or unguarded wildcard
# without rule-id; OR (under --strict) any grandfathered entry is present
# 2 = usage/config error (file not found, invalid arguments)
#
# Annotation syntax (sidecar comment, must immediately precede the path line):
# # allow: <pattern> reason="..." owner="..." expires="YYYY-MM-DD" [rule-id="..."]
# <pattern>
#
# Required annotation keys: reason, owner, expires
# Optional annotation key: rule-id (REQUIRED when pattern contains '*' wildcards)
#
# Grandfathered entries: paths with any preceding plain comment (not a structured
# annotation) are treated as grandfathered in default mode — exit 0 with a
# deprecation warning to stderr. Under --strict, grandfathered entries cause exit 1.
#
# Design references:
# - GitGuardian exclusion annotation convention:
# https://docs.gitguardian.com/internal-repositories-monitoring/integrations/cli/secrets
# - CNCF Security TAG threat-model exception lifecycle:
# https://github.com/cncf/tag-security/blob/main/community/working-groups/threat-modeling/templates/threats.md
#
# Exit-code alignment with secret-scan.sh:
# Both scripts use 0=clean, 1=policy-violation/findings, 2=usage/config-error.
# The symmetry is intentional — CI can treat either non-zero as a gate failure.
set -euo pipefail
# ─── Argument Parsing ─────────────────────────────────────────────────────────
STRICT=false
IGNOREFILE=""
usage() {
echo "Usage: $0 --file <path-to-.secretscanignore> [--strict]" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case "$1" in
--file)
shift
[[ $# -eq 0 ]] && usage
IGNOREFILE="$1"
shift
;;
--strict)
STRICT=true
shift
;;
-h|--help)
usage
;;
*)
echo "Error: unknown argument: $1" >&2
usage
;;
esac
done
if [[ -z "$IGNOREFILE" ]]; then
usage
fi
if [[ ! -f "$IGNOREFILE" ]]; then
echo "Error: file not found: $IGNOREFILE" >&2
exit 2
fi
# ─── Date Helpers ─────────────────────────────────────────────────────────────
# Returns today's date as YYYY-MM-DD (portable across macOS and Linux)
today_date() {
date +%Y-%m-%d
}
# Returns true (0) if date1 < date2 (both YYYY-MM-DD strings)
date_is_past() {
local check_date="$1"
local today
today=$(today_date)
# Lexicographic comparison works for ISO-8601 dates
[[ "$check_date" < "$today" ]]
}
# ─── Annotation Parser ────────────────────────────────────────────────────────
# Returns value of a key="value" or key='value' pair from a string.
# Usage: extract_key <string> <key>
extract_key() {
local str="$1"
local key="$2"
# Match key="value" or key='value'
local val
val=$(echo "$str" | grep -oE "${key}=['\"][^'\"]+['\"]" | head -1 | sed "s/${key}=['\"]//;s/['\"]$//") || true
echo "$val"
}
# Returns true (0) if the string contains a wildcard glob character (* or **)
contains_wildcard() {
local pattern="$1"
[[ "$pattern" == *"*"* ]]
}
# Returns true (0) if a comment line is a structured annotation
# (must start with "# allow:" prefix)
is_structured_annotation() {
local comment="$1"
[[ "$comment" =~ ^#[[:space:]]+allow:[[:space:]] ]]
}
# ─── Main Lint Logic ──────────────────────────────────────────────────────────
VIOLATIONS=0
WARNINGS=0
# We process the file line-by-line, tracking the comment immediately preceding
# each path entry. If the preceding line was a comment, we inspect it.
prev_comment=""
lineno=0
while IFS= read -r line || [[ -n "$line" ]]; do
lineno=$((lineno + 1))
# Skip empty lines (reset prev_comment to avoid false association)
if [[ -z "${line// }" ]]; then
prev_comment=""
continue
fi
# Accumulate comment lines
if [[ "$line" =~ ^[[:space:]]*# ]]; then
prev_comment="$line"
continue
fi
# This is a path/pattern entry.
local_path="$line"
# ── Case 1: No preceding comment at all ────────────────────────────────────
if [[ -z "$prev_comment" ]]; then
echo "VIOLATION (line $lineno): '$local_path' has no annotation comment." >&2
echo " Required: # allow: <pattern> reason=\"...\" owner=\"...\" expires=\"YYYY-MM-DD\"" >&2
VIOLATIONS=$((VIOLATIONS + 1))
prev_comment=""
continue
fi
# ── Case 2: Preceding comment exists — check if it's a structured annotation ─
if is_structured_annotation "$prev_comment"; then
# Extract required keys
reason=$(extract_key "$prev_comment" "reason")
owner=$(extract_key "$prev_comment" "owner")
expires=$(extract_key "$prev_comment" "expires")
rule_id=$(extract_key "$prev_comment" "rule-id")
local_ok=true
if [[ -z "$reason" ]]; then
echo "VIOLATION (line $lineno): '$local_path' annotation missing required key: reason" >&2
local_ok=false
fi
if [[ -z "$owner" ]]; then
echo "VIOLATION (line $lineno): '$local_path' annotation missing required key: owner" >&2
local_ok=false
fi
if [[ -z "$expires" ]]; then
echo "VIOLATION (line $lineno): '$local_path' annotation missing required key: expires" >&2
local_ok=false
elif date_is_past "$expires"; then
echo "VIOLATION (line $lineno): '$local_path' annotation 'expires' date is in the past: $expires" >&2
echo " Review this exclusion and update or remove it." >&2
local_ok=false
fi
if contains_wildcard "$local_path" && [[ -z "$rule_id" ]]; then
echo "VIOLATION (line $lineno): '$local_path' uses a wildcard but is missing required key: rule-id" >&2
echo " Wildcard exclusions (** *.ext) require an explicit rule-id for auditability." >&2
local_ok=false
fi
if [[ "$local_ok" == false ]]; then
VIOLATIONS=$((VIOLATIONS + 1))
fi
else
# ── Case 3: Preceding comment is plain (not structured) — grandfathered ──
if [[ "$STRICT" == true ]]; then
echo "VIOLATION (line $lineno): '$local_path' is grandfathered (no structured annotation) — rejected under --strict mode." >&2
echo " Add: # allow: $local_path reason=\"...\" owner=\"...\" expires=\"YYYY-MM-DD\"" >&2
VIOLATIONS=$((VIOLATIONS + 1))
else
echo "WARNING (line $lineno): '$local_path' is grandfathered (missing structured annotation)." >&2
echo " DEPRECATION: migrate to structured annotation before removing grandfather status." >&2
echo " Required: # allow: $local_path reason=\"...\" owner=\"...\" expires=\"YYYY-MM-DD\"" >&2
echo " See: https://docs.gitguardian.com/internal-repositories-monitoring/integrations/cli/secrets" >&2
WARNINGS=$((WARNINGS + 1))
fi
fi
prev_comment=""
done < "$IGNOREFILE"
# ─── Summary ──────────────────────────────────────────────────────────────────
if [[ $VIOLATIONS -gt 0 ]]; then
echo "secret-scan-lint: $VIOLATIONS violation(s) found" >&2
if [[ $STRICT == true ]]; then
echo "secret-scan-lint: --strict mode active — grandfathered entries are not permitted" >&2
fi
exit 1
fi
if [[ $WARNINGS -gt 0 ]]; then
echo "secret-scan-lint: $WARNINGS grandfathered entry/entries (deprecation warning)" >&2
fi
echo "secret-scan-lint: OK"
exit 0

View File

@@ -2,16 +2,46 @@
# secret-scan.sh — Check files for accidentally committed secrets/credentials
#
# Usage:
# scripts/secret-scan.sh --diff origin/main # CI mode: scan changed files
# scripts/secret-scan.sh --file path/to/file # Scan a single file
# scripts/secret-scan.sh --dir agents/ # Scan all files in a directory
# scripts/secret-scan.sh --diff origin/main # CI mode: scan changed files
# scripts/secret-scan.sh --file path/to/file # Scan a single file
# scripts/secret-scan.sh --dir agents/ # Scan all files in a directory
# scripts/secret-scan.sh --diff origin/main --strict # Strict/release mode
#
# Flags:
# --strict Reduced-exclusion mode for release and security-audit CI lanes.
# Under --strict:
# - Grandfathered (un-annotated) .secretscanignore entries are
# treated as FAILURES rather than silently honoured.
# - Exclusions whose 'expires' date is in the past are ignored
# (the file IS scanned, not skipped).
# This flag does not change secret-detection logic — only which
# exclusions are applied.
#
# Exit codes:
# 0 = clean
# 1 = findings detected
# 2 = usage error
#
# Annotation format for .secretscanignore (required for --strict compliance):
# # allow: <pattern> reason="..." owner="..." expires="YYYY-MM-DD" [rule-id="..."]
# <pattern>
#
# Design references:
# - GitGuardian exclusion annotation convention:
# https://docs.gitguardian.com/internal-repositories-monitoring/integrations/cli/secrets
# - CNCF Security TAG threat-model exception lifecycle:
# https://github.com/cncf/tag-security/blob/main/community/working-groups/threat-modeling/templates/threats.md
#
# Periodic reduced-exclusion scan procedure:
# Run this script with --strict on every release branch and during scheduled
# security reviews. This mode intentionally skips grandfathered entries and
# expired exclusions so that accumulated technical debt in the ignore-list
# cannot permanently hide secrets. See SECURITY.md for the audit runbook.
set -euo pipefail
# ─── Global mode flag ─────────────────────────────────────────────────────────
STRICT_MODE=false
# ─── Secret Patterns ─────────────────────────────────────────────────────────
# Format: "LABEL:::REGEX"
# Each entry is a human label paired with a POSIX extended regex.
@@ -57,18 +87,100 @@ SECRET_PATTERNS=(
)
# ─── Ignorelist ──────────────────────────────────────────────────────────────
#
# Entries in IGNORED_FILES are loaded from .secretscanignore.
# In --strict mode, only fully-annotated entries with a future 'expires' date
# are loaded. Grandfathered entries and expired entries are skipped (the
# corresponding files ARE scanned, not excluded).
#
# Annotation format (structured comment must immediately precede the path):
# # allow: <pattern> reason="..." owner="..." expires="YYYY-MM-DD" [rule-id="..."]
# <pattern>
#
# Entries without a structured annotation are grandfathered:
# - Default mode: accepted (file excluded), deprecation warning emitted
# - Strict mode: rejected (file scanned, no exclusion applied)
IGNOREFILE=".secretscanignore"
IGNORED_FILES=()
# Returns value of key="value" annotation pair from a string
_extract_annotation_key() {
local str="$1"
local key="$2"
echo "$str" | grep -oE "${key}=['\"][^'\"]+['\"]" | head -1 | sed "s/${key}=['\"]//;s/['\"]$//" || true
}
# Returns today as YYYY-MM-DD
_today() {
date +%Y-%m-%d
}
# Returns 0 (true) if a date string YYYY-MM-DD is strictly in the past
_date_is_past() {
local d="$1"
[[ "$d" < "$(_today)" ]]
}
load_ignorelist() {
if [[ -f "$IGNOREFILE" ]]; then
while IFS= read -r line; do
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ -z "${line// }" ]] && continue
IGNORED_FILES+=("$line")
done < "$IGNOREFILE"
if [[ ! -f "$IGNOREFILE" ]]; then
return
fi
local prev_comment=""
while IFS= read -r line || [[ -n "$line" ]]; do
# Empty line resets context
if [[ -z "${line// }" ]]; then
prev_comment=""
continue
fi
# Accumulate comment
if [[ "$line" =~ ^[[:space:]]*# ]]; then
prev_comment="$line"
continue
fi
# This is a path entry
local pattern="$line"
# Determine if preceding comment is a structured annotation
local is_structured=false
if [[ "$prev_comment" =~ ^#[[:space:]]+allow:[[:space:]] ]]; then
is_structured=true
fi
if [[ "$is_structured" == true ]]; then
# Parse structured annotation
local expires
expires=$(_extract_annotation_key "$prev_comment" "expires")
if [[ -n "$expires" ]] && _date_is_past "$expires"; then
# Expired exclusion — never apply, regardless of mode
echo "secret-scan: WARNING: exclusion '$pattern' has expired (expires=$expires) — entry ignored" >&2
prev_comment=""
continue
fi
# Valid structured annotation — always apply
IGNORED_FILES+=("$pattern")
else
# Grandfathered (plain comment or no comment)
if [[ "$STRICT_MODE" == true ]]; then
# Strict mode: do NOT apply grandfathered exclusion
echo "secret-scan: WARNING (--strict): grandfathered exclusion '$pattern' not applied" >&2
else
# Default mode: apply but warn
echo "secret-scan: DEPRECATION WARNING: '$pattern' has no structured annotation — grandfather applied" >&2
echo " Migrate to: # allow: $pattern reason=\"...\" owner=\"...\" expires=\"YYYY-MM-DD\"" >&2
IGNORED_FILES+=("$pattern")
fi
fi
prev_comment=""
done < "$IGNOREFILE"
}
is_ignored() {
@@ -106,6 +218,7 @@ should_skip_file() {
# Skip the scan scripts themselves and test files
case "$file" in
*/secret-scan.sh) return 0 ;;
*/secret-scan-lint.test.cjs) return 0 ;;
*/security-scan.test.cjs) return 0 ;;
*/security-prompt-injection.test.cjs) return 0 ;;
tests/fixtures/adversarial/security/*|*/tests/fixtures/adversarial/security/*) return 0 ;;
@@ -186,7 +299,23 @@ scan_file() {
main() {
if [[ $# -eq 0 ]]; then
echo "Usage: $0 --diff [base] | --file <path> | --dir <path>" >&2
echo "Usage: $0 --diff [base] | --file <path> | --dir <path> [--strict]" >&2
exit 2
fi
# Parse --strict flag first (may appear anywhere in argv)
local remaining_args=()
for arg in "$@"; do
if [[ "$arg" == "--strict" ]]; then
STRICT_MODE=true
else
remaining_args+=("$arg")
fi
done
set -- "${remaining_args[@]}"
if [[ $# -eq 0 ]]; then
echo "Usage: $0 --diff [base] | --file <path> | --dir <path> [--strict]" >&2
exit 2
fi

View File

@@ -0,0 +1,477 @@
/**
* Tests for secret-scan exclusion governance (issue #115):
* - scripts/secret-scan-lint.sh (new: lint policy for .secretscanignore)
* - scripts/secret-scan.sh --strict (new flag: reduced-exclusion scan mode)
*
* Design references:
* - GitGuardian exclusion annotation convention:
* https://docs.gitguardian.com/internal-repositories-monitoring/integrations/cli/secrets
* - CNCF Security TAG threat-model exception lifecycle:
* https://github.com/cncf/tag-security/blob/main/community/working-groups/threat-modeling/templates/threats.md
*
* Exit-code contract for secret-scan-lint.sh:
* 0 = every exclusion has full annotation OR is grandfathered (with warning)
* 1 = annotation violation (missing required key, expired date, unguarded wildcard without rule-id)
* 2 = config-format error (file not found, parse error)
*
* Exit-code contract for secret-scan.sh (unchanged):
* 0 = clean
* 1 = findings detected
* 2 = usage error
*
* Annotation syntax (sidecar comment on preceding line):
* # allow: <pattern> reason="..." owner="..." expires="YYYY-MM-DD" [rule-id="..."]
* <pattern>
*/
'use strict';
// allow-test-rule: source-text-is-the-product
// Justification: this file tests scan scripts and ignore-file policy where
// the textual output IS the deployed contract. Asserting exit codes and
// stderr/stdout content is a typed behavioral check on the linter's output
// protocol. Migrating to a parsed IR would add ceremony without changing
// what is verified — the strings ARE the typed surface.
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { execFileSync, spawnSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const PROJECT_ROOT = path.join(__dirname, '..');
const LINT_SCRIPT = path.join(PROJECT_ROOT, 'scripts', 'secret-scan-lint.sh');
const SECRET_SCAN = path.join(PROJECT_ROOT, 'scripts', 'secret-scan.sh');
const IS_WINDOWS = process.platform === 'win32';
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Run secret-scan-lint.sh with a given .secretscanignore fixture content.
* Writes fixture to a temp dir and invokes the linter pointing at it.
* Uses spawnSync so both stdout and stderr are always captured regardless of exit code.
*/
function runLint(ignoreContent, extraArgs = []) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sslint-test-'));
const ignoreFile = path.join(tmpDir, '.secretscanignore');
fs.writeFileSync(ignoreFile, ignoreContent, 'utf-8');
try {
const args = ['--file', ignoreFile, ...extraArgs];
const result = spawnSync(LINT_SCRIPT, args, {
encoding: 'utf-8',
timeout: 10000,
});
return {
status: result.status !== null ? result.status : 1,
stdout: result.stdout || '',
stderr: result.stderr || '',
};
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 });
}
}
/**
* Run secret-scan.sh --file <path> [extraArgs] against a file with given content.
* Returns { status, stdout, stderr }.
*/
function runSecretScan(fileContent, extraArgs = []) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ssscan-test-'));
const tmpFile = path.join(tmpDir, 'test-input.txt');
fs.writeFileSync(tmpFile, fileContent, 'utf-8');
try {
const args = ['--file', tmpFile, ...extraArgs];
const result = spawnSync(SECRET_SCAN, args, {
encoding: 'utf-8',
timeout: 10000,
});
return {
status: result.status !== null ? result.status : 1,
stdout: result.stdout || '',
stderr: result.stderr || '',
};
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 });
}
}
/**
* Run secret-scan.sh --dir <dir> [extraArgs] and count effective exclusions
* by checking how many files in a known fixture set are skipped vs scanned.
* Uses spawnSync with a generous timeout for large directories.
*/
function runSecretScanDir(dirPath, extraArgs = []) {
const args = ['--dir', dirPath, ...extraArgs];
const result = spawnSync(SECRET_SCAN, args, {
encoding: 'utf-8',
timeout: 60000,
cwd: PROJECT_ROOT,
});
return {
status: result.status !== null ? result.status : 1,
stdout: result.stdout || '',
stderr: result.stderr || '',
};
}
// ─── Script Existence ─────────────────────────────────────────────────────────
describe('secret-scan-lint.sh script exists and is executable', { skip: IS_WINDOWS }, () => {
test('lint script exists', () => {
assert.ok(fs.existsSync(LINT_SCRIPT), `Missing: ${LINT_SCRIPT}`);
});
test('lint script is executable', () => {
const stat = fs.statSync(LINT_SCRIPT);
const isExecutable = (stat.mode & 0o111) !== 0;
assert.ok(isExecutable, `${LINT_SCRIPT} is not executable`);
});
test('lint script has bash shebang', () => {
const firstLine = fs.readFileSync(LINT_SCRIPT, 'utf-8').split('\n')[0];
assert.ok(
firstLine.startsWith('#!/usr/bin/env bash') || firstLine.startsWith('#!/bin/bash'),
`${LINT_SCRIPT} missing bash shebang: ${firstLine}`
);
});
});
// ─── Test 1: Fully-annotated entry → exit 0 ──────────────────────────────────
describe('lint: fully-annotated exclusion', { skip: IS_WINDOWS }, () => {
test('exits 0 when all required keys are present and expires is in the future', () => {
// Valid annotation: reason, owner, expires all present; expires is future date
const fixture = [
'# allow: fixtures/** reason="adversarial test fixtures" owner="@security" expires="2099-12-31" rule-id="EXCLUSION-FIXTURES"',
'fixtures/**',
'',
].join('\n');
const result = runLint(fixture);
assert.equal(result.status, 0, `Expected exit 0, got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
});
test('exits 0 without optional rule-id when wildcard is not present', () => {
// Non-wildcard path: rule-id is optional
const fixture = [
'# allow: path/to/file.md reason="illustrative examples" owner="@docs" expires="2099-06-30"',
'path/to/file.md',
'',
].join('\n');
const result = runLint(fixture);
assert.equal(result.status, 0, `Expected exit 0, got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
});
});
// ─── Test 2: Missing `reason` → exit 1 ───────────────────────────────────────
describe('lint: missing required annotation keys', { skip: IS_WINDOWS }, () => {
test('exits 1 when annotation is missing reason', () => {
const fixture = [
'# allow: fixtures/** owner="@security" expires="2099-12-31" rule-id="EXCLUSION-FIXTURES"',
'fixtures/**',
'',
].join('\n');
const result = runLint(fixture);
assert.equal(result.status, 1, `Expected exit 1 (missing reason), got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
});
test('exits 1 when annotation is missing owner', () => {
const fixture = [
'# allow: fixtures/** reason="test fixtures" expires="2099-12-31" rule-id="EXCLUSION-FIXTURES"',
'fixtures/**',
'',
].join('\n');
const result = runLint(fixture);
assert.equal(result.status, 1, `Expected exit 1 (missing owner), got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
});
test('exits 1 when annotation is missing expires', () => {
const fixture = [
'# allow: fixtures/** reason="test fixtures" owner="@security" rule-id="EXCLUSION-FIXTURES"',
'fixtures/**',
'',
].join('\n');
const result = runLint(fixture);
assert.equal(result.status, 1, `Expected exit 1 (missing expires), got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
});
test('exits 1 when path has no annotation comment at all', () => {
// A bare path with no preceding # allow: comment — NOT grandfathered
// (grandfathered entries have any preceding comment, but lack structured keys)
const fixture = [
'some/path/to/file.md',
'',
].join('\n');
const result = runLint(fixture);
// A completely bare entry (no preceding comment at all) is also a policy violation
assert.equal(result.status, 1, `Expected exit 1 (bare path, no annotation), got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
});
});
// ─── Test 3: Expired `expires` date → exit 1 ─────────────────────────────────
describe('lint: expired exclusion date', { skip: IS_WINDOWS }, () => {
test('exits 1 when expires date is in the past', () => {
const fixture = [
'# allow: old/path.md reason="temporary workaround" owner="@eng" expires="2020-01-01" rule-id="EXCLUSION-OLD"',
'old/path.md',
'',
].join('\n');
const result = runLint(fixture);
assert.equal(result.status, 1, `Expected exit 1 (expired), got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
});
test('exits 1 when expires date is today minus one day (strictly past)', () => {
// Use a known-past date that will never be "today" during the test run
const fixture = [
'# allow: old/path.md reason="workaround" owner="@eng" expires="2000-12-31"',
'old/path.md',
'',
].join('\n');
const result = runLint(fixture);
assert.equal(result.status, 1, `Expected exit 1 (expired), got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
});
test('exits 0 when expires date is in the future', () => {
const fixture = [
'# allow: new/path.md reason="current workaround" owner="@eng" expires="2099-12-31"',
'new/path.md',
'',
].join('\n');
const result = runLint(fixture);
assert.equal(result.status, 0, `Expected exit 0 (future expires), got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
});
});
// ─── Test 4: Wildcard without rule-id → exit 1 ───────────────────────────────
describe('lint: wildcard exclusions require rule-id', { skip: IS_WINDOWS }, () => {
test('exits 1 for ** wildcard without rule-id', () => {
const fixture = [
'# allow: fixtures/** reason="test fixtures" owner="@security" expires="2099-12-31"',
'fixtures/**',
'',
].join('\n');
const result = runLint(fixture);
assert.equal(result.status, 1, `Expected exit 1 (wildcard ** without rule-id), got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
});
test('exits 1 for *.ext wildcard without rule-id', () => {
const fixture = [
'# allow: tests/*.json reason="fixture files" owner="@test" expires="2099-12-31"',
'tests/*.json',
'',
].join('\n');
const result = runLint(fixture);
assert.equal(result.status, 1, `Expected exit 1 (wildcard *.json without rule-id), got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
});
test('exits 0 for ** wildcard WITH rule-id and all required keys', () => {
const fixture = [
'# allow: fixtures/** reason="adversarial test fixtures" owner="@security" expires="2099-12-31" rule-id="EXCLUSION-FIXTURES"',
'fixtures/**',
'',
].join('\n');
const result = runLint(fixture);
assert.equal(result.status, 0, `Expected exit 0 (wildcard with rule-id), got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
});
});
// ─── Test 5: Grandfathered entry (default vs --strict) ───────────────────────
describe('lint: grandfathered entries (backward compat)', { skip: IS_WINDOWS }, () => {
// A grandfathered entry: has a plain comment (not a structured annotation)
// preceding the path. Under default mode: exit 0 with warning to stderr.
// Under --strict: exit 1.
const GRANDFATHER_FIXTURE = [
'# plan-phase.md contains illustrative DATABASE_URL/REDIS_URL examples',
'get-shit-done/workflows/plan-phase.md',
'',
].join('\n');
test('exits 0 on grandfathered entry in default mode', () => {
const result = runLint(GRANDFATHER_FIXTURE);
assert.equal(result.status, 0, `Expected exit 0 (grandfathered, default mode), got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
});
test('emits deprecation warning to stderr on grandfathered entry (not stdout)', () => {
const result = runLint(GRANDFATHER_FIXTURE);
// The warning MUST appear on stderr — CI log parsers read stdout for structured output.
// A warning on stdout would corrupt any downstream JSON/structured parser.
assert.ok(
result.stderr.toLowerCase().includes('warn') ||
result.stderr.toLowerCase().includes('grandfather') ||
result.stderr.toLowerCase().includes('deprecat'),
`Expected deprecation warning on stderr, but stderr was: "${result.stderr}"\nstdout was: "${result.stdout}"`
);
// Confirm the OK signal is on stdout (not buried in stderr noise)
assert.ok(
result.stdout.includes('OK') || result.stdout.trim() === '' || result.status === 0,
`Expected clean stdout (OK), got: "${result.stdout}"`
);
});
test('exits 1 on grandfathered entry under --strict mode', () => {
const result = runLint(GRANDFATHER_FIXTURE, ['--strict']);
assert.equal(result.status, 1, `Expected exit 1 (grandfathered, --strict mode), got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
});
test('exits 2 when --file argument is missing', () => {
try {
execFileSync(LINT_SCRIPT, [], {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 5000,
});
assert.fail('Should have exited non-zero');
} catch (err) {
assert.equal(err.status, 2, `Expected exit 2 (usage error), got ${err.status}`);
}
});
});
// ─── Test 6: --strict reduces effective exclusion list ───────────────────────
describe('secret-scan.sh --strict: reduces effective exclusions', { skip: IS_WINDOWS }, () => {
test('--strict flag on a clean file exits 0 (not a usage error)', () => {
// A file with no secrets and no .secretscanignore in the CWD must exit 0 under --strict.
// This verifies the flag is parsed correctly and doesn't cause a usage error (exit 2).
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'strict-test-'));
const tmpFile = path.join(tmpDir, 'clean.txt');
fs.writeFileSync(tmpFile, '# just a comment, no secrets here\n', 'utf-8');
try {
const result = spawnSync(SECRET_SCAN, ['--file', tmpFile, '--strict'], {
encoding: 'utf-8',
timeout: 10000,
cwd: tmpDir, // No .secretscanignore here — clean workspace
});
const status = result.status !== null ? result.status : 1;
// A clean file with no secrets must exit 0 under --strict
assert.equal(status, 0, `--strict on a clean file should exit 0, got ${status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test('--strict treats grandfathered exclusion entries as active (not skipped)', () => {
// Set up a temp workspace with:
// .secretscanignore — grandfathered entry (plain comment, no structured annotation)
// secret-file.txt — file that WOULD trip the scanner (contains a mock secret pattern)
//
// Under default mode: file is excluded → scan reports 0 files (or 0 findings).
// Under --strict: grandfathered entry not honoured → file IS scanned → findings exit 1.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'strict-exclusion-test-'));
const secretFile = path.join(tmpDir, 'secret-file.txt');
const ignoreFile = path.join(tmpDir, '.secretscanignore');
// A file containing a pattern that the scanner catches
// Constructed to avoid GitHub push-protection triggering on this test file itself
const awsKeyPrefix = 'AKIA';
const awsKeyBody = 'IOSFODNN7EXAMPLE1234';
fs.writeFileSync(secretFile, `aws_key = "${awsKeyPrefix}${awsKeyBody}"\n`, 'utf-8');
// Grandfathered ignore entry: plain comment, no structured annotation
fs.writeFileSync(ignoreFile, [
'# This file contains a fake key for testing purposes',
'secret-file.txt',
'',
].join('\n'), 'utf-8');
try {
// Use relative path so the case-pattern in .secretscanignore matches.
// .secretscanignore contains 'secret-file.txt'; --file 'secret-file.txt'
// (relative) matches via bash case $file in $pattern.
const relFile = 'secret-file.txt';
// Default mode: grandfathered entry IS honoured → file is excluded → exit 0
const defaultResult = spawnSync(SECRET_SCAN, ['--file', relFile], {
encoding: 'utf-8',
timeout: 10000,
cwd: tmpDir, // CWD has .secretscanignore with the grandfathered entry
});
const defaultStatus = defaultResult.status !== null ? defaultResult.status : 1;
// Strict mode: grandfathered entry NOT honoured → file is scanned → exit 1
const strictResult = spawnSync(SECRET_SCAN, ['--file', relFile, '--strict'], {
encoding: 'utf-8',
timeout: 10000,
cwd: tmpDir, // Same CWD, same .secretscanignore
});
const strictStatus = strictResult.status !== null ? strictResult.status : 0;
assert.equal(defaultStatus, 0,
`Default mode should exclude grandfathered entry (exit 0), got ${defaultStatus}.\nstdout: ${defaultResult.stdout}\nstderr: ${defaultResult.stderr}`
);
assert.equal(strictStatus, 1,
`Strict mode should scan grandfathered file and find secrets (exit 1), got ${strictStatus}.\nstdout: ${strictResult.stdout}\nstderr: ${strictResult.stderr}`
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
// ─── Test 7: Default mode regression — existing behavior unchanged ────────────
describe('secret-scan.sh default mode: regression test', { skip: IS_WINDOWS }, () => {
test('existing .secretscanignore entry is still honoured in default mode', () => {
// The file get-shit-done/workflows/plan-phase.md is listed in .secretscanignore.
// Default mode must honour that exclusion: scanning the file directly should
// show "scanned 0 files" (the ignorelist causes it to be skipped entirely).
const planPhase = path.join(PROJECT_ROOT, 'get-shit-done', 'workflows', 'plan-phase.md');
if (!fs.existsSync(planPhase)) {
// File doesn't exist in this branch — skip gracefully
return;
}
// Run scanner with --file on the excluded path, from project root so
// .secretscanignore is found. Excluded → scanned 0 files → exit 0.
const result = spawnSync(SECRET_SCAN, ['--file', 'get-shit-done/workflows/plan-phase.md'], {
encoding: 'utf-8',
timeout: 10000,
cwd: PROJECT_ROOT,
});
const status = result.status !== null ? result.status : 1;
const stdout = result.stdout || '';
assert.equal(status, 0,
`plan-phase.md should be excluded in default mode (exit 0), got ${status}.\nstdout: ${stdout}\nstderr: ${result.stderr}`
);
// The file is ignored by is_ignored() → scan_file returns 0 → no FAIL in output
assert.ok(
!stdout.includes('FAIL'),
`plan-phase.md should not appear as FAIL in default mode: ${stdout}`
);
});
test('scanner still detects real secrets in default mode', () => {
// Regression: --strict flag must not affect default-mode secret detection
const content = `DATABASE_URL=postgresql://user:realpassword@host:5432/db\n`;
const result = runSecretScan(content);
assert.equal(result.status, 1, `Expected secret to be detected in default mode`);
assert.ok(result.stdout.includes('Env Variable') || result.stdout.includes('FAIL'), `Expected FAIL output: ${result.stdout}`);
});
test('passing --strict to secret-scan.sh does not break clean file scan', () => {
// A file with no secrets should still exit 0 under --strict
const content = '# Just a config file\nsome_setting = "non-secret-value"\n';
const result = runSecretScan(content, ['--strict']);
assert.equal(result.status, 0, `Clean file should exit 0 under --strict, got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
});
});