Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 67 additions & 18 deletions src/rules_builtin.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -74,29 +74,78 @@ std::optional<Match> match_jwt(const Reader& r, size_t off) {
return m;
}

// PEM private-key body: anchor "-----BEGIN "; require "PRIVATE KEY-----" within
// a short run of key-type words. Structural, not entropy-gated.
// PEM base64 body alphabet (standard, plus '=' padding). Newlines / whitespace
// are handled by the body scanner, not this predicate.
inline bool c_base64_pem(uint8_t c) { return c_alnum(c) || c == '+' || c == '/' || c == '='; }

// PEM private-key block: anchor "-----BEGIN "; parse the BEGIN header, then require
// three things a real key has and a library's embedded PEM label string does not
// (GH #26): (1) the header's own key type is a *private* key ("... PRIVATE KEY"),
// so a "PUBLIC KEY" / "CERTIFICATE" label is never reported as a private key;
// (2) a substantial base64 body follows the header; and (3) the block closes with
// the matching "-----END <same type>-----". Crypto libraries (mbedtls, OpenSSL,
// wolfSSL) embed the bare BEGIN/END label strings back to back in .rodata with no
// body between them; those must not be reported. The returned match length is the
// header line only (a marker, not secret bytes) -- the body scan is just a gate.
std::optional<Match> match_pem_private(const Reader& r, size_t off) {
constexpr size_t PREFIX = 11; // "-----BEGIN "
const char* tail = "PRIVATE KEY-----";
const size_t tlen = 16;
for (size_t k = 0; k <= 40; ++k) {
auto b = r.bytes(off + PREFIX + k, tlen);
const uint8_t* DASH5 = reinterpret_cast<const uint8_t*>("-----");

// 1. Read the key-type token, from after "-----BEGIN " up to the closing
// "-----" of the header. Type words are uppercase letters and single spaces
// (e.g. "RSA PRIVATE KEY", "ENCRYPTED PRIVATE KEY", "OPENSSH PRIVATE KEY").
constexpr size_t MAX_TYPE = 40;
size_t typelen = 0;
for (; typelen <= MAX_TYPE; ++typelen) {
auto b = r.bytes(off + PREFIX + typelen, 5);
if (!b) return std::nullopt;
if (std::equal(b->begin(), b->end(), reinterpret_cast<const uint8_t*>(tail))) {
size_t hdr = PREFIX + k + tlen;
Match m;
m.len = hdr;
m.confidence = Confidence::Structural;
std::string header = token_str(r, off, hdr);
std::string kt = pem_key_type(header);
m.label = header; // the header line is a marker, not secret bytes
m.evidence = "PEM private-key header";
m.description = "Private key" + (kt.empty() ? std::string() : " (" + kt + ")");
return m;
if (std::equal(b->begin(), b->end(), DASH5)) break;
uint8_t c = (*b)[0];
if (!((c >= 'A' && c <= 'Z') || c == ' ')) return std::nullopt;
}
if (typelen == 0 || typelen > MAX_TYPE) return std::nullopt;
std::string type = token_str(r, off + PREFIX, typelen);

// 2. The header must itself be a PRIVATE KEY label (rejects PUBLIC KEY,
// CERTIFICATE, EC PARAMETERS, DH PARAMETERS, ...).
const std::string PK = "PRIVATE KEY";
if (type.size() < PK.size() || type.compare(type.size() - PK.size(), PK.size(), PK) != 0)
return std::nullopt;

const size_t hdr_end = off + PREFIX + typelen + 5; // past "-----BEGIN <TYPE>-----"

// 3. Require a real base64 body, then the matching "-----END <TYPE>-----".
// The first 5-dash run we meet must be that END and must be preceded by at
// least MIN_BODY base64 chars; otherwise (BEGIN immediately followed by an
// END/BEGIN, wrong END type, or no body) this is a bare label -> reject.
const std::string end_marker = "-----END " + type + "-----";
const auto* end_bytes = reinterpret_cast<const uint8_t*>(end_marker.data());
constexpr size_t WINDOW = 1u << 16; // 64 KiB: ample for any PEM key body
constexpr size_t MIN_BODY = 64; // smallest real key body is far larger than this
size_t body_b64 = 0;
bool closed = false;
for (size_t p = hdr_end; p < hdr_end + WINDOW; ++p) {
auto b = r.bytes(p, 5);
if (!b) return std::nullopt;
if (std::equal(b->begin(), b->end(), DASH5)) {
if (body_b64 >= MIN_BODY &&
r.matches_at(p, std::span<const uint8_t>(end_bytes, end_marker.size())))
closed = true;
break; // first dash-run decides it: matching END with body, or reject
}
if (c_base64_pem((*b)[0])) ++body_b64;
}
return std::nullopt;
if (!closed) return std::nullopt;

Match m;
m.len = PREFIX + typelen + 5; // header line only; a marker, not secret bytes
m.confidence = Confidence::Structural;
std::string header = token_str(r, off, m.len);
std::string kt = pem_key_type(header);
m.label = header;
m.evidence = "PEM private-key block (base64 body + matching END)";
m.description = "Private key" + (kt.empty() ? std::string() : " (" + kt + ")");
return m;
}

// Generic assignment: KEY <sep> <high-entropy value>. Noisy -> hard entropy gate
Expand Down
6 changes: 5 additions & 1 deletion tests/test_secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,13 @@ def build_tree(d):
# canonical AWS example key: must be filtered as a false positive.
f.write('const demo = "AKIAIOSFODNN7EXAMPLE";\n')

# A realistic on-disk private key: a real body (several base64 lines) and the
# matching END. The detector requires a body, so a bare header is not enough (#26).
with open(os.path.join(d, "etc/id_rsa"), "w") as f:
f.write("-----BEGIN OPENSSH PRIVATE KEY-----\n")
f.write("b3BlbnNzaC1rZXktdjEAAAAABG5vbmU\n")
body = "b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz"
for _ in range(6):
f.write(body + "\n")
f.write("-----END OPENSSH PRIVATE KEY-----\n")

# A base64 config blob hiding an AWS key (decode-then-scan path).
Expand Down
50 changes: 48 additions & 2 deletions tests/unit/unit_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@ static bool has_type(const std::vector<ft::Finding>& fs, const std::string& t) {
return find_type(fs, t) != nullptr;
}

// A well-formed PEM private-key block of `type` with a substantial base64 body
// (three 64-char lines) and the matching END, for the detector's body gate (#26).
static std::string pem_block(const std::string& type) {
const std::string line =
"MIIEpAIBAAKCAQEA0Z3VS5JJcds3xfnygWyF0qFrTfCK3myL7GTdrz6iApW5R2t8W";
return "-----BEGIN " + type + "-----\n" + line + "\n" + line + "\n" + line + "\n" +
"-----END " + type + "-----\n";
}

static std::string b64(const std::string& in) {
static const char* T = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string o;
Expand Down Expand Up @@ -181,7 +190,7 @@ static void test_detectors() {
CHECK(has_type(scan("glpat-" "AbCdEf0123456789xYzQ end"), "gitlab-pat"));
CHECK(has_type(scan("k=sk_live_" "0123456789abcdefABCDEF01 end"), "stripe-key"));
CHECK(has_type(scan("key AIzaabcdefghijklmnopqrstuvwxyz012345678 x"), "google-api-key"));
CHECK(has_type(scan("-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n"), "private-key"));
CHECK(has_type(scan(pem_block("RSA PRIVATE KEY")), "private-key"));
CHECK(has_type(scan("password = s3cr3tP@ssw0rd_9xQ7zLmN end"), "generic-secret"));
}

Expand Down Expand Up @@ -255,10 +264,46 @@ static void test_validators_pem() {
CHECK(ft::pem_key_type("-----BEGIN RSA PRIVATE KEY-----") == "RSA");
CHECK(ft::pem_key_type("-----BEGIN OPENSSH PRIVATE KEY-----") == "OPENSSH");
CHECK(ft::pem_key_type("-----BEGIN PRIVATE KEY-----") == "");
auto fs = scan("-----BEGIN EC PRIVATE KEY-----\nMHc...\n");
// A real block (header + body + matching END) is Structural with the type noted.
auto fs = scan(pem_block("EC PRIVATE KEY"));
const ft::Finding* f = find_type(fs, "private-key");
CHECK(f && f->confidence == static_cast<uint8_t>(ft::Confidence::Structural));
CHECK(f && f->description.find("EC") != std::string::npos);
// PKCS#8 unadorned and encrypted headers are private-key labels too.
CHECK(has_type(scan(pem_block("PRIVATE KEY")), "private-key"));
CHECK(has_type(scan(pem_block("ENCRYPTED PRIVATE KEY")), "private-key"));
}

// GH #26: crypto libraries (mbedtls/OpenSSL/wolfSSL) embed the bare PEM label
// strings in .rodata with no key body. None of these must yield a private-key.
static void test_pem_label_false_positives() {
// PEM begin-markers below are written as two adjacent string literals
// ("-----BEGIN " "<TYPE>-----"), which the compiler concatenates to the exact
// bytes at runtime while the committed source holds no contiguous PEM header
// (the repo convention for synthetic key fixtures; keeps secret scanners quiet).
// Bare header, no body, no END.
CHECK(!has_type(scan("-----BEGIN " "RSA PRIVATE KEY-----\n"), "private-key"));
// BEGIN immediately followed by its END, zero body.
CHECK(!has_type(scan("-----BEGIN " "RSA PRIVATE KEY-----\n-----END RSA PRIVATE KEY-----\n"),
"private-key"));
// A PUBLIC KEY label (with a real body) is never a private key.
CHECK(!has_type(scan(pem_block("PUBLIC KEY")), "private-key"));
// Type mismatch: a real body but the END type differs from the BEGIN type.
CHECK(!has_type(scan("-----BEGIN " "RSA PRIVATE KEY-----\n" +
std::string(80, 'A') + "\n-----END EC PRIVATE KEY-----\n"),
"private-key"));
// The exact mbedtls .rodata layout: NUL-separated labels packed back to back,
// including a BEGIN PUBLIC KEY adjacent to an END RSA PRIVATE KEY (target #2).
std::string labels;
for (const char* s : {"-----BEGIN " "RSA PRIVATE KEY-----", "-----END RSA PRIVATE KEY-----",
"-----BEGIN " "EC PRIVATE KEY-----", "-----END EC PRIVATE KEY-----",
"-----BEGIN " "PUBLIC KEY-----", "-----END PUBLIC KEY-----",
"-----BEGIN " "PUBLIC KEY-----", "-----END RSA PRIVATE KEY-----",
"-----BEGIN " "ENCRYPTED PRIVATE KEY-----"}) {
labels += s;
labels.push_back('\0');
}
CHECK(scan(labels).empty());
}

// ---------------------------------------------------------------- glob + path rules
Expand Down Expand Up @@ -1756,6 +1801,7 @@ int main() {
test_validators_github_crc();
test_validators_jwt();
test_validators_pem();
test_pem_label_false_positives();
test_derkey();
test_glob();
test_path_rules();
Expand Down
Loading