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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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",
|