package main

import (
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"math"
	"regexp"
	"sort"
	"strings"
)

// ---------------------------------------------------------------------------
// Secret detection
//
// Two independent stages, both running over the RECONSTRUCTED TEXT of a cast
// (every event payload concatenated in recorded order) plus the header's
// command line and title:
//
//   1. Named pattern detectors. Each one knows the shape of one specific
//      credential family, and each one declares how many leading bytes are a
//      PUBLIC marker that may stay visible after masking ("AKIA", "ghp_",
//      "xoxb-"). Everything after the marker is masked.
//
//   2. A Shannon-entropy detector for credentials no pattern names, gated by a
//      length floor, a character-class requirement and a de-noising pass that
//      throws out the usual sources of false positives (UUIDs, digests in
//      hash-like contexts, base64-encoded plain text, all-numeric tokens).
//
// The mask character '*' is deliberately outside the token alphabet of every
// pattern detector, so masked output cannot re-trigger them. The two detectors
// whose value grammar does accept '*' (secret assignments and connection-string
// passwords) are covered by the mask-dominated guard in scanText.
// ---------------------------------------------------------------------------

const (
	maskChar = '*'

	// Entropy gate. Shannon entropy is measured in bits per character.
	// Reference points for the alphabet we accept ([A-Za-z0-9+/=_-]):
	//   English prose        ~2.5 - 3.5
	//   lower-case hex       <= 4.0 (log2 16)
	//   random base64        ~5.5 - 6.0 (log2 64 = 6.0)
	// A 20-character string cannot exceed log2(20) = 4.32 bits/char, so a
	// threshold of 4.0 at the length floor demands roughly 17 distinct
	// characters out of 20 - i.e. something that really does look random.
	entropyMinLength = 20
	entropyThreshold = 4.0

	// Second, looser gate for long strings that do not have all three of
	// lower/upper/digit (e.g. a 40-char all-lowercase random token). The bar is
	// raised to compensate for the missing character-class evidence.
	entropyLongLength    = 32
	entropyLongThreshold = 4.5

	// A candidate AWS secret access key is only reported when an "aws secret"
	// style context word appears within this many bytes before it, because the
	// bare shape (40 base64 characters) is far too common to report alone.
	contextWindow = 80

	// Assignment values shorter than this are treated as placeholders.
	minAssignedValue = 3
)

// span is one detected region of a text, in byte offsets.
type span struct {
	start, end int
	keep       int // leading bytes that are a public marker, left unmasked
	detector   string
	entropy    float64
}

func (s span) length() int { return s.end - s.start }

// detectorInfo documents one detector for --json output and the README.
type detectorInfo struct {
	Name     string `json:"name"`
	Rank     int    `json:"rank"`
	Severity string `json:"severity"`
	Desc     string `json:"description"`
}

// Higher rank wins when two detectors claim overlapping text.
var detectorCatalog = []detectorInfo{
	{"private-key-pem", 100, "critical", "PEM private key block (RSA/EC/OPENSSH/PGP), header to footer"},
	{"aws-access-key-id", 95, "critical", "AKIA/ASIA followed by 16 upper-case alphanumerics"},
	{"github-token", 95, "critical", "ghp_/gho_/ghs_/ghu_/ghr_ + 36 chars, or github_pat_ + 22-82 chars"},
	{"google-api-key", 95, "high", "AIza followed by 35 URL-safe characters"},
	{"slack-token", 95, "high", "xox[baprs]- followed by 10 or more token characters"},
	{"jwt", 90, "high", "three base64url segments whose header decodes to JSON carrying alg/typ"},
	{"aws-secret-access-key", 85, "critical", "40 base64 characters within 80 bytes of an aws-secret context word"},
	{"connection-string-password", 80, "critical", "inline password in a scheme://user:pass@host URL"},
	{"bearer-token", 70, "high", "Bearer <token> of 12 or more characters, as in an Authorization header"},
	{"secret-assignment", 60, "high", "PASSWORD= / SECRET= / TOKEN= / API_KEY= style assignment value"},
	{"high-entropy-string", 10, "medium", "unnamed high-entropy string surviving the de-noising pass"},
}

func detectorRank(name string) int {
	for _, d := range detectorCatalog {
		if d.Name == name {
			return d.Rank
		}
	}
	return 0
}

func detectorSeverity(name string) string {
	for _, d := range detectorCatalog {
		if d.Name == name {
			return d.Severity
		}
	}
	return "medium"
}

func detectorDesc(name string) string {
	for _, d := range detectorCatalog {
		if d.Name == name {
			return d.Desc
		}
	}
	return ""
}

// ---------------------------------------------------------------------------
// Patterns
// ---------------------------------------------------------------------------

var (
	rePEMBlock = regexp.MustCompile(
		`-----BEGIN [A-Z0-9 ]{0,32}PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END [A-Z0-9 ]{0,32}PRIVATE KEY(?: BLOCK)?-----`)
	rePEMHeader = regexp.MustCompile(`-----BEGIN [A-Z0-9 ]{0,32}PRIVATE KEY(?: BLOCK)?-----`)

	reAWSKeyID = regexp.MustCompile(`(?:AKIA|ASIA)[0-9A-Z]{16}`)

	reAWSSecretCand = regexp.MustCompile(`[A-Za-z0-9/+]{40}`)
	reAWSSecretCtx  = regexp.MustCompile(`(?i)aws[_\-. ]?(?:secret|session)|secret[_\-. ]?access[_\-. ]?key|secret[_\-. ]?key`)

	reGitHub = regexp.MustCompile(`gh[pousr]_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{22,82}`)

	reGoogleAPI = regexp.MustCompile(`AIza[0-9A-Za-z_\-]{35}`)

	reSlack = regexp.MustCompile(`xox[baprs]-[A-Za-z0-9\-]{10,}`)

	reJWT = regexp.MustCompile(`[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{6,}\.[A-Za-z0-9_\-]*`)

	reBearer = regexp.MustCompile(`(?i)bearer[ \t]+([A-Za-z0-9._~+/=\-]{12,})`)

	// The value classes below deliberately exclude control bytes and non-ASCII
	// so that a credential sitting in front of binary output does not drag the
	// binary tail into the mask.
	reConnString = regexp.MustCompile(
		`(?i)\b[a-z][a-z0-9+.\-]{1,15}://[^:@/\s"']{1,64}:([^@/\s"'\x00-\x1f\x7f[:^ascii:]]{1,128})@`)

	reAssignment = regexp.MustCompile(
		`(?i)[A-Za-z0-9_.\-]{0,40}(?:passwords?|passwd|passphrase|secrets?|tokens?|api[_\-]?keys?|access[_\-]?keys?|auth[_\-]?tokens?|private[_\-]?keys?|client[_\-]?secrets?|credentials?)\b[ \t]*[:=][ \t]*(?:"([^"\n]{1,512})"|'([^'\n]{1,512})'|([^\s"'\n;,&|<>\x00-\x1f\x7f[:^ascii:]]{1,512}))`)

	// '=' is accepted only as trailing base64 padding. Allowing it in the
	// middle would glue an assignment to its value and turn
	// "AWS_CREDENTIALS=/etc/aws/credentials" into one 36-character candidate.
	reEntropyCand = regexp.MustCompile(`[A-Za-z0-9+/_\-]{20,}={0,2}`)

	reUUID = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)

	reHashContext = regexp.MustCompile(`(?i)sha-?(?:1|256|512)|md5|blake[23]|digest|checksum|etag|commit|revision|integrity|fingerprint|imageid|content-hash`)

	reVarRef = regexp.MustCompile(`^(?:\$\{?[A-Za-z_][A-Za-z0-9_]*\}?|%[A-Za-z_][A-Za-z0-9_]*%)$`)

	rePathish = regexp.MustCompile(`^(?:~?/|\./|\.\./|[A-Za-z]:\\|\\\\)`)
)

var placeholderValues = map[string]bool{
	"true": true, "false": true, "null": true, "nil": true, "none": true,
	"yes": true, "no": true, "on": true, "off": true, "empty": true,
	"redacted": true, "removed": true, "hidden": true, "masked": true,
	"placeholder": true, "example": true, "todo": true, "tbd": true,
	"undefined": true, "unset": true, "default": true,
}

// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------

// scanText runs every detector over text and returns the surviving,
// non-overlapping spans sorted by offset.
func scanText(text string) []span {
	if text == "" {
		return nil
	}
	var all []span
	all = append(all, findPEM(text)...)
	all = append(all, findAWSKeyID(text)...)
	all = append(all, findGitHub(text)...)
	all = append(all, findGoogleAPI(text)...)
	all = append(all, findSlack(text)...)
	all = append(all, findJWT(text)...)
	all = append(all, findAWSSecret(text)...)
	all = append(all, findConnString(text)...)
	all = append(all, findBearer(text)...)
	all = append(all, findAssignment(text)...)
	all = append(all, findHighEntropy(text)...)

	// Global guard: never re-report something that is already mostly mask.
	// This is what lets a redacted file re-scan clean even for the two
	// detectors whose value grammar happens to accept '*' (secret-assignment
	// and connection-string-password): after redaction their value is a run of
	// asterisks, possibly behind a public marker like "xoxb-". A value that is
	// half mask characters has already been redacted by someone.
	kept := all[:0]
	for _, s := range all {
		if s.start < 0 || s.end > len(text) || s.length() <= 0 {
			continue
		}
		if maskDominated(text[s.start:s.end]) {
			continue
		}
		if s.keep > s.length() {
			s.keep = s.length()
		}
		if s.entropy == 0 {
			s.entropy = shannonEntropy(text[s.start:s.end])
		}
		kept = append(kept, s)
	}
	return resolveOverlaps(kept)
}

// resolveOverlaps keeps the highest-ranked, then longest, then earliest span
// whenever two detectors claim the same bytes.
func resolveOverlaps(in []span) []span {
	if len(in) == 0 {
		return nil
	}
	ordered := append([]span(nil), in...)
	sort.SliceStable(ordered, func(i, j int) bool {
		ri, rj := detectorRank(ordered[i].detector), detectorRank(ordered[j].detector)
		if ri != rj {
			return ri > rj
		}
		if ordered[i].length() != ordered[j].length() {
			return ordered[i].length() > ordered[j].length()
		}
		return ordered[i].start < ordered[j].start
	})
	var out []span
	for _, s := range ordered {
		overlap := false
		for _, k := range out {
			if s.start < k.end && k.start < s.end {
				overlap = true
				break
			}
		}
		if !overlap {
			out = append(out, s)
		}
	}
	sort.SliceStable(out, func(i, j int) bool { return out[i].start < out[j].start })
	return out
}

// ---------------------------------------------------------------------------
// Individual detectors
// ---------------------------------------------------------------------------

func findPEM(text string) []span {
	var out []span
	covered := map[int]bool{}
	for _, m := range rePEMBlock.FindAllStringIndex(text, -1) {
		// The whole block is masked, marker lines included: leaving the BEGIN
		// line visible would let the detector re-fire on redacted output.
		out = append(out, span{start: m[0], end: m[1], keep: 0, detector: "private-key-pem"})
		for i := m[0]; i < m[1]; i++ {
			covered[i] = true
		}
	}
	for _, m := range rePEMHeader.FindAllStringIndex(text, -1) {
		if covered[m[0]] {
			continue
		}
		// Truncated recording: the BEGIN marker without its END. Mask what we
		// can see - the marker line plus the rest of the captured text on it.
		end := m[1]
		if nl := strings.IndexByte(text[m[1]:], '\n'); nl >= 0 {
			end = m[1] + nl
		} else {
			end = len(text)
		}
		out = append(out, span{start: m[0], end: end, keep: 0, detector: "private-key-pem"})
	}
	return out
}

func findAWSKeyID(text string) []span {
	var out []span
	for _, m := range reAWSKeyID.FindAllStringIndex(text, -1) {
		if !alnumBoundary(text, m[0], m[1]) {
			continue
		}
		out = append(out, span{start: m[0], end: m[1], keep: 4, detector: "aws-access-key-id"})
	}
	return out
}

func findGitHub(text string) []span {
	var out []span
	for _, m := range reGitHub.FindAllStringIndex(text, -1) {
		if !alnumBoundary(text, m[0], m[1]) {
			continue
		}
		tok := text[m[0]:m[1]]
		keep := strings.LastIndexByte(tok[:min(len(tok), 12)], '_') + 1
		out = append(out, span{start: m[0], end: m[1], keep: keep, detector: "github-token"})
	}
	return out
}

func findGoogleAPI(text string) []span {
	var out []span
	for _, m := range reGoogleAPI.FindAllStringIndex(text, -1) {
		if !alnumBoundary(text, m[0], m[1]) {
			continue
		}
		out = append(out, span{start: m[0], end: m[1], keep: 4, detector: "google-api-key"})
	}
	return out
}

func findSlack(text string) []span {
	var out []span
	for _, m := range reSlack.FindAllStringIndex(text, -1) {
		if m[0] > 0 && isTokenByte(text[m[0]-1]) {
			continue
		}
		out = append(out, span{start: m[0], end: m[1], keep: 5, detector: "slack-token"})
	}
	return out
}

// findJWT requires the first segment to base64url-decode to a JSON object
// carrying "alg" or "typ". Shape alone matches far too much (any dotted
// identifier triple); a decodable header is what makes it a JWT.
func findJWT(text string) []span {
	var out []span
	for _, m := range reJWT.FindAllStringIndex(text, -1) {
		if m[0] > 0 && isTokenByte(text[m[0]-1]) {
			continue
		}
		if m[1] < len(text) && isTokenByte(text[m[1]]) {
			continue
		}
		tok := text[m[0]:m[1]]
		parts := strings.Split(tok, ".")
		if len(parts) != 3 {
			continue
		}
		if !jwtHeaderDecodes(parts[0]) {
			continue
		}
		out = append(out, span{start: m[0], end: m[1], keep: 0, detector: "jwt"})
	}
	return out
}

func jwtHeaderDecodes(seg string) bool {
	raw, err := base64.RawURLEncoding.DecodeString(seg)
	if err != nil {
		if raw, err = base64.URLEncoding.DecodeString(seg); err != nil {
			return false
		}
	}
	var obj map[string]any
	if err := json.Unmarshal(raw, &obj); err != nil {
		return false
	}
	_, hasAlg := obj["alg"]
	_, hasTyp := obj["typ"]
	return hasAlg || hasTyp
}

// findAWSSecret is context-anchored: 40 base64 characters is an extremely
// common shape, so it is only a finding when an aws-secret context word is
// close in front of it and the string itself carries enough entropy.
func findAWSSecret(text string) []span {
	var out []span
	for _, m := range reAWSSecretCand.FindAllStringIndex(text, -1) {
		// A '=' in FRONT is an assignment operator, not base64 padding, so it
		// must not disqualify the candidate - that is exactly the
		// AWS_SECRET_ACCESS_KEY=... case this detector exists for.
		if m[0] > 0 && isB64Byte(text[m[0]-1]) && text[m[0]-1] != '=' {
			continue
		}
		if m[1] < len(text) && isB64Byte(text[m[1]]) {
			continue
		}
		lo := m[0] - contextWindow
		if lo < 0 {
			lo = 0
		}
		if !reAWSSecretCtx.MatchString(text[lo:m[0]]) {
			continue
		}
		tok := text[m[0]:m[1]]
		if e := shannonEntropy(tok); e < 3.5 {
			continue
		}
		out = append(out, span{start: m[0], end: m[1], keep: 0, detector: "aws-secret-access-key"})
	}
	return out
}

func findConnString(text string) []span {
	var out []span
	for _, m := range reConnString.FindAllStringSubmatchIndex(text, -1) {
		if m[2] < 0 {
			continue
		}
		out = append(out, span{start: m[2], end: m[3], keep: 0, detector: "connection-string-password"})
	}
	return out
}

func findBearer(text string) []span {
	var out []span
	for _, m := range reBearer.FindAllStringSubmatchIndex(text, -1) {
		if m[2] < 0 {
			continue
		}
		if m[0] > 0 && isTokenByte(text[m[0]-1]) {
			continue
		}
		out = append(out, span{start: m[2], end: m[3], keep: 0, detector: "bearer-token"})
	}
	return out
}

func findAssignment(text string) []span {
	var out []span
	for _, m := range reAssignment.FindAllStringSubmatchIndex(text, -1) {
		s, e := -1, -1
		for g := 1; g <= 3; g++ {
			if m[2*g] >= 0 {
				s, e = m[2*g], m[2*g+1]
				break
			}
		}
		if s < 0 {
			continue
		}
		if !plausibleSecretValue(text[s:e]) {
			continue
		}
		out = append(out, span{start: s, end: e, keep: 0, detector: "secret-assignment"})
	}
	return out
}

// plausibleSecretValue throws away the things that sit on the right of a
// PASSWORD=/SECRET=/TOKEN= but are obviously not credentials.
func plausibleSecretValue(v string) bool {
	if len(v) < minAssignedValue {
		return false
	}
	if maskDominated(v) {
		return false
	}
	if placeholderValues[strings.ToLower(v)] {
		return false
	}
	if reVarRef.MatchString(v) {
		return false // $VAR / ${VAR} / %VAR% - a reference, not a literal
	}
	if rePathish.MatchString(v) {
		return false // a path to where the credential lives, not the credential
	}
	if strings.HasPrefix(v, "<") && strings.HasSuffix(v, ">") {
		return false // <your-token-here>
	}
	if isAllDigits(v) && len(v) < 8 {
		return false
	}
	if distinctBytes(v) <= 1 {
		return false // "xxxxxxxx", "--------"
	}
	return true
}

// ---------------------------------------------------------------------------
// Entropy detector plus its de-noising pass
// ---------------------------------------------------------------------------

func findHighEntropy(text string) []span {
	var out []span
	for _, m := range reEntropyCand.FindAllStringIndex(text, -1) {
		tok := text[m[0]:m[1]]
		if denoise(text, m[0], tok) {
			continue
		}
		e := shannonEntropy(tok)
		lower, upper, digit, _ := charClasses(tok)
		strong := lower && upper && digit && e >= entropyThreshold && len(tok) >= entropyMinLength
		long := len(tok) >= entropyLongLength && e >= entropyLongThreshold
		if !strong && !long {
			continue
		}
		out = append(out, span{start: m[0], end: m[1], keep: 0, detector: "high-entropy-string", entropy: e})
	}
	return out
}

// denoise reports whether a high-entropy candidate should be discarded. Each
// rule here exists because without it the report fills with noise that hides
// the real findings.
func denoise(text string, start int, tok string) bool {
	// 1. Already redacted.
	if allMask(tok) {
		return true
	}
	// 2. UUIDs. Ubiquitous in logs, never a credential.
	if reUUID.MatchString(tok) {
		return true
	}
	// 3. Digests. Pure hex at a standard digest width, or any pure hex sitting
	//    next to a word like sha256/md5/commit/etag/digest.
	if isPureHex(tok) {
		switch len(tok) {
		case 32, 40, 56, 64, 96, 128:
			return true
		}
		lo := start - 48
		if lo < 0 {
			lo = 0
		}
		if reHashContext.MatchString(text[lo:start]) {
			return true
		}
	}
	// 4. All-numeric: timestamps, ids, byte counts.
	if isAllDigits(tok) {
		return true
	}
	// 5. Base64 of ordinary text - e.g. output piped through `base64`. The
	//    decode is what distinguishes it from base64 of random key material,
	//    which decodes to bytes that are mostly unprintable.
	if looksLikeEncodedText(tok) {
		return true
	}
	// 6. A single repeated character, or near enough.
	if distinctBytes(tok) < 6 {
		return true
	}
	return false
}

func shannonEntropy(s string) float64 {
	if s == "" {
		return 0
	}
	var counts [256]int
	for i := 0; i < len(s); i++ {
		counts[s[i]]++
	}
	n := float64(len(s))
	e := 0.0
	for _, c := range counts {
		if c == 0 {
			continue
		}
		p := float64(c) / n
		e -= p * math.Log2(p)
	}
	return e
}

func charClasses(s string) (lower, upper, digit, symbol bool) {
	for i := 0; i < len(s); i++ {
		switch c := s[i]; {
		case c >= 'a' && c <= 'z':
			lower = true
		case c >= 'A' && c <= 'Z':
			upper = true
		case c >= '0' && c <= '9':
			digit = true
		default:
			symbol = true
		}
	}
	return
}

func looksLikeEncodedText(tok string) bool {
	if len(tok) < 12 {
		return false
	}
	dec, err := base64.StdEncoding.DecodeString(tok)
	if err != nil {
		dec, err = base64.RawStdEncoding.DecodeString(tok)
		if err != nil {
			return false
		}
	}
	if len(dec) < 8 {
		return false
	}
	printable, letters, spaces := 0, 0, 0
	for _, b := range dec {
		if (b >= 0x20 && b <= 0x7e) || b == '\n' || b == '\r' || b == '\t' {
			printable++
		}
		if (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') {
			letters++
		}
		if b == ' ' || b == '\n' {
			spaces++
		}
	}
	n := float64(len(dec))
	if float64(printable)/n < 0.95 {
		return false
	}
	return spaces > 0 || float64(letters)/n >= 0.5
}

func isPureHex(s string) bool {
	if len(s) == 0 {
		return false
	}
	_, err := hex.DecodeString(s)
	return err == nil && len(s)%2 == 0
}

func isAllDigits(s string) bool {
	if s == "" {
		return false
	}
	for i := 0; i < len(s); i++ {
		if s[i] < '0' || s[i] > '9' {
			return false
		}
	}
	return true
}

func distinctBytes(s string) int {
	var seen [256]bool
	n := 0
	for i := 0; i < len(s); i++ {
		if !seen[s[i]] {
			seen[s[i]] = true
			n++
		}
	}
	return n
}

func allMask(s string) bool {
	if s == "" {
		return false
	}
	for i := 0; i < len(s); i++ {
		if s[i] != maskChar {
			return false
		}
	}
	return true
}

// maskDominated reports whether at least half of s is the mask character,
// which is the signature of something that has already been redacted.
func maskDominated(s string) bool {
	if s == "" {
		return false
	}
	n := 0
	for i := 0; i < len(s); i++ {
		if s[i] == maskChar {
			n++
		}
	}
	return n*2 >= len(s)
}

func isTokenByte(c byte) bool {
	return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-'
}

func isB64Byte(c byte) bool {
	return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '+' || c == '/' || c == '='
}

func alnumBoundary(text string, start, end int) bool {
	if start > 0 {
		c := text[start-1]
		if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' {
			return false
		}
	}
	if end < len(text) {
		c := text[end]
		if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' {
			return false
		}
	}
	return true
}

func min(a, b int) int {
	if a < b {
		return a
	}
	return b
}

// ---------------------------------------------------------------------------
// Masking
// ---------------------------------------------------------------------------

// maskPlan turns spans into a per-byte decision over a text of length n. Using
// a bitmap keeps overlapping spans, public-marker prefixes and cross-event
// spans all correct without any interval arithmetic at apply time.
func maskPlan(n int, spans []span) []bool {
	plan := make([]bool, n)
	for _, s := range spans {
		from := s.start + s.keep
		if from < 0 {
			from = 0
		}
		for i := from; i < s.end && i < n; i++ {
			plan[i] = true
		}
	}
	return plan
}

// applyMask rewrites every planned byte as the mask character. Length in bytes
// is unchanged by construction, which is what keeps the cast byte-for-byte
// aligned and the event timings untouched.
func applyMask(text string, plan []bool) string {
	b := []byte(text)
	for i := range b {
		if i < len(plan) && plan[i] {
			b[i] = maskChar
		}
	}
	return string(b)
}

// maskedRender renders a secret for display: the public marker, if the
// detector declared one, followed by mask characters. The secret's own bytes
// past the marker never appear.
func maskedRender(s string, keep int) string {
	if keep > len(s) {
		keep = len(s)
	}
	if keep < 0 {
		keep = 0
	}
	stars := len(s) - keep
	if stars > 40 {
		return s[:keep] + strings.Repeat("*", 40) + fmt.Sprintf("[+%d]", stars-40)
	}
	return s[:keep] + strings.Repeat("*", stars)
}

// maskHeaderString redacts a cast header field (title, one command argument)
// in place. The library index and every report store the masked form: a title
// or a recorded command line is a very common hiding place for a credential,
// and an index file is the thing people commit to a repo.
func maskHeaderString(s string) string {
	if s == "" {
		return s
	}
	spans := scanText(s)
	if len(spans) == 0 {
		return s
	}
	return applyMask(s, maskPlan(len(s), spans))
}

// maskHeaderStrings redacts a whole recorded command line.
func maskHeaderStrings(in []string) ([]string, int) {
	out := make([]string, len(in))
	n := 0
	for i, s := range in {
		out[i] = maskHeaderString(s)
		if out[i] != s {
			n++
		}
	}
	return out, n
}

// fingerprint is a truncated SHA-256 of the secret, so the same credential can
// be recognised across clips and across runs without ever being printed. It is
// a digest, not the secret - see the caveat in README.txt.
func fingerprint(s string) string {
	sum := sha256.Sum256([]byte(s))
	return "sha256:" + hex.EncodeToString(sum[:])[:12]
}
