Module: Sharekit::Cli::Redactor

Defined in:
lib/sharekit/cli/redactor.rb

Overview

Strips secret material out of a line before it can leave the machine.

Redaction runs against the original line, never a Finding's truncated preview: truncation can cut a secret mid-token so that the rule pattern no longer matches it, which would let the surviving fragment through.

A masked secret is replaced by its shape (length + Shannon entropy), which is what a classifier actually needs to tell a live key from EXAMPLE_KEY.

Constant Summary collapse

GENERIC_SECRET_PATTERN =

Backstop for high-entropy material no Scanner rule claims. Runs last, so rule-labelled hits keep their own mask.

%r{[A-Za-z0-9+/=_-]{20,}}
MASK_PREFIX =
"[REDACTED"
ENV_ASSIGNMENT_PATTERN =

Scanner's ENV_VAR_PATTERN ends in (.*)$, which swallows the rest of the line, so the first assignment is the only one it ever examines. That is fine for reporting one finding per line, but as a redaction pass it leaks: in PATH=/usr/bin API_KEY=short the boring first key short-circuits the check and the real secret is never masked, while being too short for the generic backstop. Redaction therefore matches one assignment at a time. Quoted forms are listed explicitly because a bare \S* stops at the space in KEY="a b".

/(?:^|\s)(?:export\s+)?([A-Z_]+)=("[^"]*"|'[^']*'|\S*)/

Class Method Summary collapse

Class Method Details

.entropy(str) ⇒ Object

Shannon entropy in bits per character. Random tokens land near 4+ bits; placeholders like AKIAIOSFODNN7EXAMPLE sit noticeably lower.



40
41
42
43
44
45
46
47
48
# File 'lib/sharekit/cli/redactor.rb', line 40

def entropy(str)
  return 0.0 if str.empty?

  length = str.length.to_f
  str.each_char.tally.values.sum do |count|
    probability = count / length
    -probability * Math.log2(probability)
  end
end

.mask(secret) ⇒ Object



34
35
36
# File 'lib/sharekit/cli/redactor.rb', line 34

def mask(secret)
  "#{MASK_PREFIX} chars=#{secret.length} entropy=#{format("%.1f", entropy(secret))}]"
end

.mask_env_values(line) ⇒ Object



59
60
61
62
63
64
65
66
67
68
# File 'lib/sharekit/cli/redactor.rb', line 59

def mask_env_values(line)
  line.gsub(ENV_ASSIGNMENT_PATTERN) do |hit|
    key = Regexp.last_match(1)
    value = Regexp.last_match(2)
    next hit unless Scanner::SENSITIVE_KEY_PATTERN.match?(key)
    next hit if value.empty? || value.start_with?(MASK_PREFIX)

    hit.sub(value, mask(value))
  end
end

.redact(line) ⇒ Object

Masks every secret-shaped run in line: every Scanner rule, then sensitive env-var values, then the generic backstop. All rules run, not just the one that produced the finding, so a second secret sharing the line cannot ride along into a prompt.



27
28
29
30
31
32
# File 'lib/sharekit/cli/redactor.rb', line 27

def redact(line)
  masked = Scanner::RULES.reduce(line) do |acc, rule|
    acc.gsub(rule.pattern) { |hit| mask(hit) }
  end
  mask_env_values(masked).gsub(GENERIC_SECRET_PATTERN) { |hit| mask(hit) }
end