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', () => {