mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-13 11:57:19 +01:00
* Brain sync scans binary files and split authorization values for secrets (GST-2292) Git showed a file with a NUL byte or UTF-16 text as "Binary files differ", so the push-time secret scan never saw an AWS key inside it. The sync and the pre-commit hook now diff with --text --no-textconv, the scanner also reads NUL-bearing lines as UTF-16 and with NULs removed, and a section git still did not diff as text is held as binary-not-scanned. A Bearer value added under an unchanged "authorization": line was missed because the + marker broke the match; each hunk's new text is now scanned as one block too. The docs say plainly that an already-committed secret stays held after deletion and only --skip-file clears it. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Secret scan catches split auth values in UTF-16 files and under diff.context=0 Review-Finding: p1/red-team#1 reader=red-team Review-Finding: p1/red-team#2 reader=red-team Review-Finding: p1/red-team#3 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Sync docs say a secret already committed in ~/.gstack stays held until you skip the file Approved docs-audit repair: approval a-029ea2b533be (GStack Minder GSTA-36, lane brief item 3). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Secret scan keeps the plain git diff as one reading beside the text reading The sync and the pre-commit hooks had replaced git's plain staged diff with a --text --no-textconv -U3 diff. That lost two things the old scan held: an unchanged line that the user's diff.context showed (5 lines away from an edit), and a secret that only a configured textconv driver decodes. The scanner now takes both diffs and holds a file when either reading matches; binary-not-scanned applies to the text reading only. Review-Finding: sol-audit-201c6a0#1 reader=cross-family-audit Review-Finding: sol-audit-201c6a0#2 reader=cross-family-audit Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Secret scan decodes JSON escapes in UTF-16 text and keeps its diff files private A \u-escaped AWS key inside a UTF-16 JSON file passed both scans, because the UTF-16 and NUL-stripped readings skipped the JSON decoding every other reading gets. The sync's two scan diffs, which now carry binary bytes, were written with the user's umask (0644 under /tmp); they are created 0600. Review-Finding: p3/outside-voice#1 reader=outside-voice Review-Finding: p3/outside-voice#2 reader=outside-voice Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
252 lines
10 KiB
Python
252 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
r"""Secret scan over a staged git diff, shared by gstack-brain-sync and the
|
|
pre-commit hook that gstack-artifacts-init and gstack-brain-restore install.
|
|
|
|
The whole diff is scanned as written, headers and context lines included, so
|
|
a secret in a file name or one that lines up across an unchanged line is
|
|
held. Each added line is also scanned with its JSON string literals decoded,
|
|
so a secret hidden behind \u escapes or backslash-escaped quotes is caught.
|
|
Each hunk's new text (its unchanged and added lines without their diff
|
|
markers) is scanned as one block too, so a key on an unchanged line joins a
|
|
value added under it.
|
|
|
|
Callers pass two readings of the same staged diff and every check runs on
|
|
both, so neither can lose what the other holds. The first is the plain
|
|
`git diff` the hook has always scanned, with the user's diff.context and
|
|
textconv drivers. The second is the same diff with --text, --no-textconv and
|
|
-U3, so a file git calls binary arrives as its bytes and a diff.context below
|
|
3 cannot drop the unchanged lines around an edit. A line or hunk holding a
|
|
NUL byte is also read as UTF-16 in both byte orders and alignments, and with
|
|
its NULs removed. A section of the second reading that still says "Binary
|
|
files ... differ" or carries a "GIT binary patch" was not diffed as text; it
|
|
is flagged as binary-not-scanned (fail closed).
|
|
|
|
The first six pattern families are the original hook's; the four after them
|
|
mirror lib/redact-patterns.ts HIGH rules (stripe.secret/restricted,
|
|
slack.token, npm.token, db.url_with_password) with its placeholder
|
|
exemptions, so a row the learnings and timeline writers accept is not held
|
|
here. Only the family name ever leaves this script, never the matched text.
|
|
The diff is read as bytes and decoded with replacement, so undecodable bytes
|
|
cannot crash the scan into a false "clean".
|
|
|
|
gate <diff|-> [<text-diff>] exit 0 clean, 3 hit (prints the family
|
|
names); anything else is a scanner
|
|
failure (fail closed). "-" reads stdin.
|
|
attribute <diff> <names> <out> [<text-diff>]
|
|
one pass: splits each diff on its
|
|
"diff --git" headers, pairs each section
|
|
with the NUL-separated names from
|
|
"git diff --name-only -z" (same order),
|
|
writes "<family>\t<path>" per flagged
|
|
path; exit 0, or 4 when sections and
|
|
names disagree
|
|
"""
|
|
import json
|
|
import re
|
|
import sys
|
|
|
|
patterns = [
|
|
('aws-access-key', re.compile(r'AKIA[0-9A-Z]{16}')),
|
|
('github-token', re.compile(r'\b(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})')),
|
|
('openai-key', re.compile(r'\bsk-[A-Za-z0-9_-]{20,}')),
|
|
('pem-block', re.compile(r'-----BEGIN [A-Z ]{3,}-----')),
|
|
('jwt', re.compile(r'\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b')),
|
|
('bearer-token-json',
|
|
# JSON-embedded auth headers. The optional Bearer/Basic/Token prefix
|
|
# matters: real auth values include a literal space after the scheme
|
|
# name, but the value charset below does not include spaces, so
|
|
# without the optional prefix every Bearer token in a JSON blob slips
|
|
# past the scanner.
|
|
re.compile(r'"(authorization|api[_-]?key|apikey|token|secret|password)"\s*:\s*"(Bearer |Basic |Token )?[A-Za-z0-9_./+=-]{16,}"',
|
|
re.IGNORECASE)),
|
|
]
|
|
|
|
structural = [re.compile(p, re.I) for p in (r'^your[_-]', r'^<[^>]*>$', r'^\*+$', r'^x{6,}$')]
|
|
substring = [re.compile(p, re.I) for p in (r'example', r'^pass(word)?$', r'^changeme$', r'^redacted',
|
|
r'^placeholder', r'^dummy', r'^fake', r'test[_-]?(key|token|secret)')]
|
|
url_password_words = {'PASSWORD', 'PASS', 'PASSWD', 'YOUR_PASSWORD', 'DB_PASSWORD', 'MY_PASSWORD',
|
|
'CHANGEME', 'CHANGE_ME', 'PLACEHOLDER', 'REDACTED', 'EXAMPLE'}
|
|
interpolated = re.compile(r'^(\$\{.+\}|\$[A-Z_][A-Z0-9_]*)$')
|
|
postgres_loopback = re.compile(r'^postgres(?:ql)?://postgres:postgres@(?:localhost|127\.0\.0\.1)(?::\d{1,5})?(?![\w.:@%-])')
|
|
json_string = re.compile(r'"(?:[^"\\\n]|\\.)*"')
|
|
|
|
|
|
def real_token(m):
|
|
span = m.group(1)
|
|
return not any(rx.search(span) for rx in structural + substring)
|
|
|
|
|
|
def real_db_url(m):
|
|
pw = m.group(2)
|
|
if pw == '' or interpolated.search(pw) or pw in url_password_words:
|
|
return False
|
|
if any(rx.search(pw) for rx in structural):
|
|
return False
|
|
return not postgres_loopback.search(m.group(1))
|
|
|
|
|
|
patterns = [(name, rx, None) for name, rx in patterns] + [
|
|
('stripe-live-key', re.compile(r'\b((?:sk|rk)_live_[A-Za-z0-9]{24,})\b'), real_token),
|
|
('slack-token', re.compile(r'\b(xox[baprs]-[A-Za-z0-9-]{10,})\b'), real_token),
|
|
('npm-token', re.compile(r'\b(npm_[A-Za-z0-9]{36})\b'), real_token),
|
|
# A diff line of a JSONL file is JSON text: the URL stops at an unescaped
|
|
# quote, so it cannot run across into the next field's "@".
|
|
('db-url-password',
|
|
re.compile(r'\b((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp)://[^:\s/@"]+:((?:[^@\s/"\\]|\\.)+)@[^\s/"]+)'),
|
|
real_db_url),
|
|
]
|
|
|
|
|
|
def decoded_strings(text, depth=3):
|
|
"""Every escaped JSON string literal in text, decoded, and the literals
|
|
those hold in turn (a JSON document stored as a string value)."""
|
|
found = []
|
|
for m in json_string.finditer(text):
|
|
if '\\' not in m.group(0):
|
|
continue
|
|
try:
|
|
value = json.loads(m.group(0))
|
|
except ValueError:
|
|
continue
|
|
found.append(value)
|
|
if depth > 1:
|
|
found.extend(decoded_strings(value, depth - 1))
|
|
return found
|
|
|
|
|
|
def added_lines(lines):
|
|
"""The added lines of a unified diff, without their "+" marker. Lines
|
|
before a section's first hunk are headers ("+++ b/path" among them)."""
|
|
out, in_hunk = [], False
|
|
for line in lines:
|
|
if line.startswith(b"diff --git "):
|
|
in_hunk = False
|
|
elif line.startswith(b"@@"):
|
|
in_hunk = True
|
|
elif in_hunk and line.startswith(b"+"):
|
|
out.append(line[1:].decode("utf-8", "replace"))
|
|
return out
|
|
|
|
|
|
def post_images(lines):
|
|
"""Each hunk's new text as bytes: its unchanged and added lines, markers
|
|
removed, joined on the newline byte git split them at."""
|
|
out, cur, in_hunk = [], [], False
|
|
for line in lines:
|
|
if line.startswith(b"diff --git ") or line.startswith(b"@@"):
|
|
if cur:
|
|
out.append(b"\n".join(cur))
|
|
cur, in_hunk = [], line.startswith(b"@@")
|
|
elif in_hunk and line[:1] in (b" ", b"+"):
|
|
cur.append(line[1:])
|
|
if cur:
|
|
out.append(b"\n".join(cur))
|
|
return out
|
|
|
|
|
|
def nul_readings(bodies):
|
|
"""Each byte string holding a NUL byte, read with its NULs removed and as
|
|
UTF-16LE and UTF-16BE from both byte alignments."""
|
|
out = []
|
|
for body in bodies:
|
|
if b"\0" not in body:
|
|
continue
|
|
out.append(body.replace(b"\0", b"").decode("utf-8", "replace"))
|
|
for chunk in (body, body[1:]):
|
|
chunk = chunk[:len(chunk) // 2 * 2]
|
|
out.append(chunk.decode("utf-16-le", "replace"))
|
|
out.append(chunk.decode("utf-16-be", "replace"))
|
|
return out
|
|
|
|
|
|
def unscanned_binary(lines):
|
|
"""True when a section's header says git did not diff a file as text."""
|
|
in_hunk = False
|
|
for line in lines:
|
|
if line.startswith(b"diff --git "):
|
|
in_hunk = False
|
|
elif line.startswith(b"@@"):
|
|
in_hunk = True
|
|
elif not in_hunk and (line.startswith(b"Binary files ") or line == b"GIT binary patch"):
|
|
return True
|
|
return False
|
|
|
|
|
|
def decoded_in_place(text):
|
|
"""text with every escaped JSON string literal replaced by its decoded
|
|
value in quotes, so a key and its value are matched together even when
|
|
either is written with escapes."""
|
|
def decode(m):
|
|
if '\\' not in m.group(0):
|
|
return m.group(0)
|
|
try:
|
|
return '"' + json.loads(m.group(0)) + '"'
|
|
except ValueError:
|
|
return m.group(0)
|
|
return json_string.sub(decode, text)
|
|
|
|
|
|
def families(lines, text_reading=False):
|
|
added = added_lines(lines)
|
|
raw = b"\n".join(lines).decode("utf-8", "replace")
|
|
posts = post_images(lines)
|
|
post_texts = [post.decode("utf-8", "replace") for post in posts]
|
|
texts = ([raw] + [decoded_in_place(line) for line in added] + [s for line in added for s in decoded_strings(line)]
|
|
+ post_texts + [decoded_in_place(text) for text in post_texts]
|
|
+ [t for nul in nul_readings([line[1:] for line in lines] + posts)
|
|
for t in [nul, decoded_in_place(nul)] + decoded_strings(nul)])
|
|
text = "\n".join(texts)
|
|
found = ['binary-not-scanned'] if text_reading and unscanned_binary(lines) else []
|
|
return found + [name for name, rx, real in patterns
|
|
if any(real is None or real(m) for m in rx.finditer(text))]
|
|
|
|
|
|
def read(path):
|
|
if path == "-":
|
|
return sys.stdin.buffer.read()
|
|
with open(path, "rb") as f:
|
|
return f.read()
|
|
|
|
|
|
def union(plain, text):
|
|
hits = families(plain)
|
|
if text is not None:
|
|
hits += [h for h in families(text, text_reading=True) if h not in hits]
|
|
return hits
|
|
|
|
|
|
def sections(path):
|
|
out = []
|
|
for line in read(path).split(b"\n"):
|
|
if line.startswith(b"diff --git "):
|
|
out.append([line])
|
|
elif out:
|
|
out[-1].append(line)
|
|
return out
|
|
|
|
|
|
mode = sys.argv[1]
|
|
if mode == "gate":
|
|
text = read(sys.argv[3]).split(b"\n") if len(sys.argv) > 3 else None
|
|
hits = union(read(sys.argv[2]).split(b"\n"), text)
|
|
if hits:
|
|
print(",".join(hits))
|
|
sys.exit(3)
|
|
sys.exit(0)
|
|
if mode != "attribute":
|
|
sys.exit(2)
|
|
|
|
diff_path, names_path, out_path = sys.argv[2:5]
|
|
names = [n.decode("utf-8", "surrogateescape") for n in read(names_path).split(b"\0") if n]
|
|
plain = sections(diff_path)
|
|
text = sections(sys.argv[5]) if len(sys.argv) > 5 else [None] * len(plain)
|
|
if len(plain) != len(names) or len(text) != len(names):
|
|
sys.exit(4)
|
|
with open(out_path, "w", encoding="utf-8", errors="surrogateescape", newline="\n") as out:
|
|
for name, body, text_body in zip(names, plain, text):
|
|
if "\n" in name or "\t" in name:
|
|
sys.exit(4)
|
|
hits = union(body, text_body)
|
|
if hits:
|
|
out.write(hits[0] + "\t" + name + "\n")
|