tewip-ai 0.70.14 → 0.70.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ // Is every place Tewip is published actually carrying this release?
2
+ //
3
+ // There is a standing rule on this project: check every distribution channel after every
4
+ // improvement, because a stale channel looks exactly like a current one. It was being done by hand,
5
+ // and by hand it failed twice in one day - not because the check was skipped, but because the
6
+ // question was wrong. The MCP registry was asked `?search=<name>&limit=100`, which returns a capped
7
+ // version history and came back without the two newest releases in it. It answered with a version
8
+ // number, the version number looked plausible, and it would have looked just as plausible if
9
+ // nothing were wrong.
10
+ //
11
+ // So the verdict here has three values, not two. `unverified` is the whole point of the file: a
12
+ // channel nobody could read is not a channel that is up to date, and must never be rendered as one.
13
+ /** Compares two dotted versions. Missing parts count as zero, so 0.70 is older than 0.70.1. */
14
+ export function compareVersions(a, b) {
15
+ const parts = (v) => v.split('.').map((n) => Number.parseInt(n, 10) || 0);
16
+ const [x, y] = [parts(a), parts(b)];
17
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
18
+ const d = (x[i] ?? 0) - (y[i] ?? 0);
19
+ if (d !== 0)
20
+ return d < 0 ? -1 : 1;
21
+ }
22
+ return 0;
23
+ }
24
+ export function judge(reading) {
25
+ if (reading.actual === null) {
26
+ return {
27
+ ...reading,
28
+ verdict: 'unverified',
29
+ says: `could not be read${reading.problem ? `: ${reading.problem}` : ''}. This is not the same as being up to date - nothing here says whether ${reading.name} has ${reading.expected} or not.`,
30
+ };
31
+ }
32
+ const order = compareVersions(reading.actual, reading.expected);
33
+ if (order === 0)
34
+ return { ...reading, verdict: 'current', says: `has ${reading.actual}.` };
35
+ if (order < 0) {
36
+ return { ...reading, verdict: 'behind', says: `has ${reading.actual}, but this repository is at ${reading.expected}. The release did not reach it.` };
37
+ }
38
+ // Ahead is not an error on its own: the editor extension keeps its own version line, and a
39
+ // channel can legitimately carry something this checkout has not caught up with.
40
+ return { ...reading, verdict: 'ahead', says: `has ${reading.actual}, which is newer than this checkout's ${reading.expected}.` };
41
+ }
42
+ const MARK = { current: 'ok', behind: 'BEHIND', ahead: 'ahead', unverified: 'UNVERIFIED' };
43
+ /** The whole answer, written so the two states that need a person are impossible to skim past. */
44
+ export function renderChannels(readings) {
45
+ const judged = readings.map(judge);
46
+ const width = Math.max(...judged.map((c) => c.name.length), 12);
47
+ const lines = ['Where Tewip is published', ''];
48
+ for (const c of judged)
49
+ lines.push(` ${MARK[c.verdict].padEnd(11)} ${c.name.padEnd(width)} ${c.says}`);
50
+ const behind = judged.filter((c) => c.verdict === 'behind');
51
+ const unverified = judged.filter((c) => c.verdict === 'unverified');
52
+ lines.push('');
53
+ if (behind.length > 0) {
54
+ lines.push(`${behind.length} channel(s) did not get this release: ${behind.map((c) => c.name).join(', ')}.`, '');
55
+ }
56
+ if (unverified.length > 0) {
57
+ // Named separately from `behind` on purpose. Folding the two together is how an unanswered
58
+ // query becomes a clean report.
59
+ lines.push(`${unverified.length} channel(s) could not be checked at all: ${unverified.map((c) => c.name).join(', ')}. Nothing above says whether those are current.`, '');
60
+ }
61
+ if (behind.length === 0 && unverified.length === 0)
62
+ lines.push('Every channel answered, and every one has this release.', '');
63
+ lines.push('How each was asked:');
64
+ for (const c of judged)
65
+ lines.push(` ${c.name}: ${c.asked}`);
66
+ return { text: lines.join(String.fromCharCode(10)), behind: behind.length, unverified: unverified.length };
67
+ }
@@ -71,10 +71,74 @@ const DOC_FILE = /(?:^|[\\/])(?:\.env\.example|\.env\.sample|\.env\.template|[^\
71
71
  * there is, and a key pasted into an example file by accident is one of the commonest ways
72
72
  * credentials leak. That case is now shown as a template finding instead of being dismissed.
73
73
  */
74
- export function looksPlanted(value, line, file, before = '') {
74
+ /**
75
+ * A URL-shaped credential whose destination cannot be a real system, or whose user and password
76
+ * are the same word.
77
+ *
78
+ * A credential matters because it is a way in to something. `postgres://saleor:saleor@localhost`
79
+ * is a way in to the machine it is already running on, and `user:password@example.com` points at
80
+ * a domain reserved by RFC 2606 precisely so that it cannot be anybody's system.
81
+ *
82
+ * Measured on Saleor at its pinned commit: six findings were reported as needing someone now, at
83
+ * high severity, each telling the team to "revoke it at the issuer first". Three were the Django
84
+ * local-development default in settings.py, one of them on a commented-out line, and the evidence
85
+ * offered was that the value "has the randomness of a real credential" - about the word `saleor`.
86
+ *
87
+ * Nothing is dropped. The finding becomes a placeholder finding, shown as noise with its reason
88
+ * attached. Suppressing a real secret is far worse than reporting a false one, so the two tests
89
+ * are narrow: a destination that provably reaches nobody, and a password identical to its own
90
+ * username, which nobody chooses for a credential that guards anything.
91
+ */
92
+ /** Domains that exist so they cannot be anybody's system (RFC 2606, RFC 6761). */
93
+ const RESERVED = /^(?:[\w-]+\.)*example\.(?:com|org|net)$|\.(?:invalid|test|localhost|local)$/i;
94
+ /** Addresses that only ever mean the machine the code is running on. */
95
+ const THIS_MACHINE = /^(?:localhost|127\.0\.0\.1|\[?::1\]?|0\.0\.0\.0)$/i;
96
+ const CREDENTIALLED_URL = /[a-z][a-z0-9+.-]*:\/\/([^:/\s'\"]+):([^@\s'\"]+)@([^/\s'\"?#]+)/gi;
97
+ /**
98
+ * A credential whose destination is the machine it runs on.
99
+ *
100
+ * This is deliberately not treated as a placeholder. A hardcoded password in settings.py is a real
101
+ * string that a person should see - an earlier phase decided exactly that, about this exact case -
102
+ * and calling it noise would hide it. What was wrong was only the urgency: Saleor's Django default
103
+ * was reported as needing someone now, at high severity, told to "revoke it at the issuer first".
104
+ * There is no issuer. It belongs in "real, but not reachable", which is the bucket for something
105
+ * true of the repository and not of what it runs.
106
+ */
107
+ export function credentialGoesNowhere(value, line) {
108
+ for (const [, , password, hostport] of [...value.matchAll(CREDENTIALLED_URL), ...[...line.matchAll(CREDENTIALLED_URL)].filter((m) => m[2] === value)]) {
109
+ if (password !== value && !value.includes(password))
110
+ continue;
111
+ const host = hostport.replace(/:\d+$/, '');
112
+ if (THIS_MACHINE.test(host))
113
+ return `the URL points at ${host}, so it reaches only the machine it runs on`;
114
+ }
115
+ return null;
116
+ }
117
+ export function urlCredentialIsHarmless(value, line) {
118
+ // The URL rules capture the whole URL as the value, not the password inside it - which is also
119
+ // why these pass the entropy gate at all, and why the report offered "the randomness of a real
120
+ // credential (3.82 bits per character)" as evidence about a string that is mostly the word
121
+ // `postgres` and a hostname. So the value is read as a URL first, and only then looked for inside
122
+ // the line, which is how the patterns that do capture a bare password arrive here.
123
+ const inValue = [...value.matchAll(CREDENTIALLED_URL)];
124
+ const inLine = [...line.matchAll(CREDENTIALLED_URL)].filter((m) => m[2] === value);
125
+ for (const [, user, password, hostport] of [...inValue, ...inLine]) {
126
+ const host = hostport.replace(/:\d+$/, '');
127
+ if (RESERVED.test(host))
128
+ return `the URL points at ${host}, a domain reserved so that it cannot be anybody's system`;
129
+ if (user.toLowerCase() === password.toLowerCase())
130
+ return 'the user and the password are the same word, which is a stand-in rather than a credential';
131
+ }
132
+ return null;
133
+ }
134
+ export function looksPlanted(value, line, file, before = '', whole = value) {
75
135
  if (PLACEHOLDER_WORDS.test(value))
76
136
  return 'the value itself says it is an example';
77
- if (PLACEHOLDER_WORDS.test(line))
137
+ // Only the words *around* the credential are commentary about it. A hostname or a database name
138
+ // inside the URL is part of the credential: a real password at db.acme-prod.example-corp.io was
139
+ // dismissed because "example" was in the line, and it was in the line because it was in the host.
140
+ const around = line.split(whole).join(' ');
141
+ if (PLACEHOLDER_WORDS.test(around))
78
142
  return 'the line around it says it is an example';
79
143
  // JSON and YAML put the key on its own line when the value is long, and an OpenAPI schema is all
80
144
  // long values. nocodb declares `"example":` and then the token underneath it, so reading only the
@@ -83,6 +147,9 @@ export function looksPlanted(value, line, file, before = '') {
83
147
  return 'the key above it says it is an example';
84
148
  if (/(.)\1{5,}/.test(value))
85
149
  return 'the value repeats one character, so it is a stand-in';
150
+ const harmless = urlCredentialIsHarmless(value, line);
151
+ if (harmless)
152
+ return harmless;
86
153
  return null;
87
154
  }
88
155
  /** True when only the file's name suggests a template, whatever the value itself looks like. */
@@ -150,7 +217,16 @@ export function scanSecrets(root, files = sourceFiles(root)) {
150
217
  for (const { kind, what, re, minEntropy } of PATTERNS) {
151
218
  for (const match of text.matchAll(re)) {
152
219
  const value = match[1];
153
- const entropy = entropyOf(value);
220
+ // Where a pattern captures the credential separately, that is what gets judged. The two
221
+ // URL rules capture the whole URL first and the password second, and judging the URL meant
222
+ // a hostname or a database name decided whether a password was real: a genuine credential
223
+ // at db.acme-prod.example-corp.io was dismissed because its host contains "example", and
224
+ // any connection string to a database called sample or demo would go the same way.
225
+ // Suppressing a real secret is the worst thing this scanner can do, and it was one word in
226
+ // an unrelated part of the string away. The whole match is still what gets redacted, so
227
+ // nothing of the URL is printed either way.
228
+ const secret = match[2] ?? value;
229
+ const entropy = entropyOf(secret);
154
230
  if (entropy < (minEntropy ?? 3))
155
231
  continue;
156
232
  const index = match.index ?? 0;
@@ -170,7 +246,8 @@ export function scanSecrets(root, files = sourceFiles(root)) {
170
246
  line,
171
247
  evidence: redact(lines[line - 1] ?? '', value),
172
248
  entropy: Number(entropy.toFixed(2)),
173
- placeholder: looksPlanted(value, lines[line - 1] ?? '', rel, lines[line - 2] ?? ''),
249
+ placeholder: looksPlanted(secret, lines[line - 1] ?? '', rel, lines[line - 2] ?? '', value),
250
+ localOnly: credentialGoesNowhere(value, lines[line - 1] ?? ''),
174
251
  template: inATemplate(rel),
175
252
  });
176
253
  }
@@ -63,6 +63,15 @@ export function triageSecret(finding, root, accepted) {
63
63
  if (finding.placeholder) {
64
64
  return { ...base, verdict: 'noise', severity: 'low', why: `${finding.placeholder}: ${finding.evidence}`, next: 'nothing: this is not a credential' };
65
65
  }
66
+ if (finding.localOnly) {
67
+ return {
68
+ ...base,
69
+ verdict: 'not-reachable',
70
+ severity: 'low',
71
+ why: `${finding.evidence} - ${finding.localOnly}`,
72
+ next: 'nothing to revoke, because nothing outside this machine accepts it. Worth replacing with a value read from the environment, on a normal schedule.',
73
+ };
74
+ }
66
75
  if (finding.template) {
67
76
  // Only the file's name says this is a template; the value itself looks like a real credential.
68
77
  // Dismissing it would hide the commonest accident there is - a working key pasted into an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tewip-ai",
3
- "version": "0.70.14",
3
+ "version": "0.70.15",
4
4
  "mcpName": "io.github.hmamut39/tewip",
5
5
  "description": "Tewip finds why each failing test failed - real bug, flaky test, environment, test data or changed selector - for any stack, from the command line, CI, or your editor.",
6
6
  "keywords": [
@@ -65,6 +65,7 @@
65
65
  "prepack": "npm run build",
66
66
  "tewip": "node cli/tewip.ts",
67
67
  "version": "node scripts/sync-version.ts && git add server.json",
68
+ "channels": "node scripts/channels.ts",
68
69
  "corpus": "node scripts/corpus.ts"
69
70
  },
70
71
  "license": "Apache-2.0",