From 1bac9c77a1aa08b631a24abe4a7fa5f63bc64b1e Mon Sep 17 00:00:00 2001 From: Petrus Pennanen Date: Sun, 20 Sep 2026 13:22:33 +0200 Subject: [PATCH] Report what a JWT claims, not what it is The scanner decoded a token's exp claim and then printed conclusions it had not earned: "LIVE until 2036" and, for a token with no exp, "this token does not stop working". Neither is knowable from decoding. We never ask an issuer anything, so a token's own statement about its lifetime proves nothing about whether it currently works or has already been revoked. A revoked key with a 2036 exp claim prints as LIVE; that is the exact direction a security tool must not be wrong in. Raised by codexmb in the post-merge review of #123 as a non-blocking follow-up. Wording is now scoped to the claim in both the stageable and history scanners, and in the EXPIRED branch too, which had the same defect in the other direction: past exp is still only a claim, and says nothing about revocation. The tests asserted the old strings, so they move with it, and they now also assert the old wording CANNOT come back. Reintroducing either phrase on a copy fails the suite, which is what makes this a guard rather than a restatement. Co-Authored-By: Claude Opus 5 --- scripts/check-stageable-secrets.mjs | 9 ++++++--- scripts/scan-history-for-secrets.mjs | 10 +++++++--- src/secret-patterns.mjs | 2 +- test/scan-history-for-secrets.test.mjs | 14 +++++++++++--- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/scripts/check-stageable-secrets.mjs b/scripts/check-stageable-secrets.mjs index 4fd2187..c86245c 100755 --- a/scripts/check-stageable-secrets.mjs +++ b/scripts/check-stageable-secrets.mjs @@ -135,9 +135,12 @@ function describeDetail(detail) { if (!detail || detail.kind !== 'jwt') return ''; const claims = renderClaims(detail.claims); let expiry; - if (detail.noExpiry) expiry = 'NO EXPIRY CLAIM'; - else if (detail.expired) expiry = `EXPIRED ${detail.expiresAt}`; - else expiry = `live until ${detail.expiresAt} (${detail.daysRemaining} days)`; + // Everything here is what the token SAYS about itself. Decoding `exp` proves + // neither that the token still works nor that it was revoked, so the wording + // stays scoped to the claim (codexmb, post-merge review of #123). + if (detail.noExpiry) expiry = 'no expiry claim; validity and revocation unverified'; + else if (detail.expired) expiry = `expiry claim: ${detail.expiresAt} (past)`; + else expiry = `expiry claim: ${detail.expiresAt} (in ${detail.daysRemaining} days)`; return `claims: ${claims} | ${expiry}`; } diff --git a/scripts/scan-history-for-secrets.mjs b/scripts/scan-history-for-secrets.mjs index ea147c2..8ac2fba 100755 --- a/scripts/scan-history-for-secrets.mjs +++ b/scripts/scan-history-for-secrets.mjs @@ -160,9 +160,13 @@ function printHuman(report) { // vocabulary we defined; everything else is presence and length. A // JWT's claims are free text chosen by whoever made the token. out(` claims: ${renderClaims(f.detail.claims)}`); - if (f.detail.noExpiry) out(' expiry: NO EXPIRY CLAIM - this token does not stop working'); - else if (f.detail.expired) out(` expiry: EXPIRED ${f.detail.expiresAt}`); - else out(` expiry: LIVE until ${f.detail.expiresAt} (${f.detail.daysRemaining} days remaining)`); + // The exp claim is the token's own statement, not evidence about it. + // "LIVE until" read as proof of validity and "does not stop working" as + // proof of permanence; neither survives the fact that we never asked an + // issuer anything (codexmb, post-merge review of #123). + if (f.detail.noExpiry) out(' expiry: no expiry claim; validity and revocation unverified'); + else if (f.detail.expired) out(` expiry: expiry claim ${f.detail.expiresAt} (past); revocation unverified`); + else out(` expiry: expiry claim ${f.detail.expiresAt} (in ${f.detail.daysRemaining} days); validity and revocation unverified`); } out(` blob: ${f.blob}`); out(` commit: ${f.commit ?? '(not attributable to a single commit)'}`); diff --git a/src/secret-patterns.mjs b/src/secret-patterns.mjs index 395f555..9e9c514 100644 --- a/src/secret-patterns.mjs +++ b/src/secret-patterns.mjs @@ -162,7 +162,7 @@ export function renderClaims(claims) { * * Claims yes, token never - and only the constrained parts of the claims, see * the note above. The point is a report that is safe to share: it says what the - * token IS (a service_role key for project x, live until 2036) without + * token CLAIMS to be (a service_role key for project x, exp 2036) without * republishing anything the token's author chose to write. * * Returns null for an eyJ-prefixed string that is not actually a JWT, which is diff --git a/test/scan-history-for-secrets.test.mjs b/test/scan-history-for-secrets.test.mjs index cc0d7e7..21c75ae 100644 --- a/test/scan-history-for-secrets.test.mjs +++ b/test/scan-history-for-secrets.test.mjs @@ -630,9 +630,17 @@ test('an expired JWT is reported and distinguished from a live one', () => { // conversation, not a non-event. assert.equal(report.findings.length, 3); const human = runScanner([dir]); - assert.match(human.stderr, /EXPIRED/); - assert.match(human.stderr, /LIVE until/); - assert.match(human.stderr, /NO EXPIRY CLAIM/); + // The report may describe what the token CLAIMS, never what it IS. We never + // asked an issuer anything, so validity and revocation are both unknown. + assert.match(human.stderr, /expiry claim [^\n]*\(past\)/); + assert.match(human.stderr, /expiry claim [^\n]*\(in \d+ days\)/); + assert.match(human.stderr, /no expiry claim; validity and revocation unverified/); + + // The wording this replaced asserted facts the scanner cannot establish: + // "LIVE until" read as proof the token still works, and "does not stop + // working" as proof it never will. Assert they cannot come back. + assert.doesNotMatch(human.stderr, /live until/i); + assert.doesNotMatch(human.stderr, /does not stop working/i); }); test('a malformed eyJ-prefixed string does not crash the scanner', () => {