tewip-ai 0.69.10 → 0.70.1
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.
|
@@ -14,7 +14,9 @@ export function verifyAttestation(file, keyFile) {
|
|
|
14
14
|
const bundle = JSON.parse(readFileSync(resolve(file), 'utf8'));
|
|
15
15
|
const publicKey = keyFile ? readFileSync(resolve(keyFile), 'utf8') : undefined;
|
|
16
16
|
const checked = check(bundle, publicKey);
|
|
17
|
-
|
|
17
|
+
// 'stripped' counts as a failure: the caller supplied a key, so it asked who made this, and a
|
|
18
|
+
// bundle with no signature cannot answer. See `check` in seal.ts for the attack that exposed it.
|
|
19
|
+
const ok = checked.contentMatches && checked.signature !== 'invalid' && checked.signature !== 'stripped';
|
|
18
20
|
const report = [
|
|
19
21
|
`# ${ok ? 'Checked' : 'Do not trust this bundle'}: ${file.split(/[\/]/).pop()}`,
|
|
20
22
|
'',
|
|
@@ -20,18 +20,24 @@ import { createHash, createPrivateKey, createPublicKey, sign as cryptoSign, veri
|
|
|
20
20
|
* different bundle.
|
|
21
21
|
*/
|
|
22
22
|
export function canonical(bundle) {
|
|
23
|
-
|
|
23
|
+
// The seal is left out of its own digest, which it has to be - but only the **top-level** one.
|
|
24
|
+
// The filter used to apply at every depth, so any field named `seal` anywhere inside the bundle
|
|
25
|
+
// was invisible to the digest: content could be added under `claims[0].seal` without changing the
|
|
26
|
+
// digest or invalidating the signature over it. Nothing reads such a field today, which is why
|
|
27
|
+
// this is small rather than serious; the claim being made is that the digest covers the content,
|
|
28
|
+
// and a digest with a hole in it is not that claim.
|
|
29
|
+
const ordered = (value, top = false) => {
|
|
24
30
|
if (Array.isArray(value))
|
|
25
|
-
return value.map(ordered);
|
|
31
|
+
return value.map((v) => ordered(v));
|
|
26
32
|
if (value && typeof value === 'object') {
|
|
27
33
|
return Object.fromEntries(Object.entries(value)
|
|
28
|
-
.filter(([k]) => k
|
|
34
|
+
.filter(([k]) => !(top && k === 'seal'))
|
|
29
35
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
30
36
|
.map(([k, v]) => [k, ordered(v)]));
|
|
31
37
|
}
|
|
32
38
|
return value;
|
|
33
39
|
};
|
|
34
|
-
return JSON.stringify(ordered(bundle));
|
|
40
|
+
return JSON.stringify(ordered(bundle, true));
|
|
35
41
|
}
|
|
36
42
|
export const digestOf = (bundle) => createHash('sha256').update(canonical(bundle)).digest('hex');
|
|
37
43
|
/**
|
|
@@ -65,10 +71,27 @@ export function check(bundle, publicKeyPem) {
|
|
|
65
71
|
return { contentMatches: false, signature: bundle.seal?.signature ? 'invalid' : 'absent', says: 'the content does not match the digest recorded inside it: it has been changed since it was written' };
|
|
66
72
|
}
|
|
67
73
|
if (!bundle.seal?.signature) {
|
|
74
|
+
// A key was given, so authorship is what was asked about - and there is nothing here to answer
|
|
75
|
+
// with. This used to return the same pass as an unsigned bundle checked without a key, so:
|
|
76
|
+
// change a fact, erase the "what was not checked" list, delete the signature, recompute the
|
|
77
|
+
// digest, and `attest --verify bundle.json --key release.pem` printed "Content matches its
|
|
78
|
+
// digest: yes" and **exited 0**. Any gate scripted on it passed. Removing a signature is the
|
|
79
|
+
// oldest attack on one, and "there is nothing to check" is a failure to establish what was
|
|
80
|
+
// asked, not a pass.
|
|
81
|
+
if (publicKeyPem?.trim()) {
|
|
82
|
+
return {
|
|
83
|
+
contentMatches: true,
|
|
84
|
+
signature: 'stripped',
|
|
85
|
+
says: 'a public key was given but this bundle carries no signature, so who produced it cannot be established. ' +
|
|
86
|
+
'The content does match the digest recorded inside it, and that is worth nothing here: anyone who edits a bundle can recompute its digest, which is exactly what removing the signature would look like',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
68
89
|
return {
|
|
69
90
|
contentMatches: true,
|
|
70
91
|
signature: 'absent',
|
|
71
|
-
|
|
92
|
+
// "no signature was made" asserted something about the bundle's history that cannot be known
|
|
93
|
+
// from the bundle: it may have been removed. What is observable is that it carries none.
|
|
94
|
+
says: 'the content matches its own digest, and nothing more: this bundle carries no signature, so this shows it is undamaged, not that nobody edited it - an editor can recompute a digest',
|
|
72
95
|
};
|
|
73
96
|
}
|
|
74
97
|
if (!publicKeyPem?.trim()) {
|
|
@@ -169,12 +169,54 @@ export async function scanWeb(url, { timeoutMs = 15_000, probeExposed = true, pa
|
|
|
169
169
|
return { findings, requests, note: null };
|
|
170
170
|
}
|
|
171
171
|
/** Localhost, a private address, or a name only this machine resolves. */
|
|
172
|
+
/** The four octets of a dotted IPv4 address, or null for anything else - including a name. */
|
|
173
|
+
function ipv4(host) {
|
|
174
|
+
const parts = host.split('.');
|
|
175
|
+
if (parts.length !== 4)
|
|
176
|
+
return null;
|
|
177
|
+
const numbers = parts.map((p) => (/^\d{1,3}$/.test(p) ? Number(p) : -1));
|
|
178
|
+
return numbers.every((n) => n >= 0 && n <= 255) ? numbers : null;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Is this address the person's own machine or network?
|
|
182
|
+
*
|
|
183
|
+
* **This is a consent control, not a convenience.** `secure --url` refuses a non-local address
|
|
184
|
+
* without `--i-am-authorised`, because scanning a system you do not run is not Tewip's decision to
|
|
185
|
+
* make - so anything this returns `true` for is scanned with no questions asked.
|
|
186
|
+
*
|
|
187
|
+
* It was a prefix match, and five hostnames walked straight past it: `localhost.evil.example`,
|
|
188
|
+
* `localhostile.example`, `10.evil.example`, `127.0.0.1.evil.example` and `172.16.evil.example` were
|
|
189
|
+
* all "this machine", because each *starts with* something local-looking. `localhost.<anything>` is
|
|
190
|
+
* one of the oldest allowlist bypasses there is, and a careless paste reaches it as easily as a
|
|
191
|
+
* malicious one.
|
|
192
|
+
*
|
|
193
|
+
* So a name is local only if it **is** `localhost` or ends in a reserved local suffix, and an address
|
|
194
|
+
* is local only if its octets really fall in a private range. Link-local 169.254/16 is deliberately
|
|
195
|
+
* **not** local: `169.254.169.254` is the cloud metadata endpoint, and asking before touching it is
|
|
196
|
+
* exactly the point of this function.
|
|
197
|
+
*/
|
|
172
198
|
export function isLocal(url) {
|
|
199
|
+
let host;
|
|
173
200
|
try {
|
|
174
|
-
|
|
175
|
-
return /^(?:localhost|127\.|0\.0\.0\.0|\[::1\]|::1|10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)/.test(host) || host.endsWith('.local') || host.endsWith('.localhost');
|
|
201
|
+
host = new URL(url).hostname.toLowerCase();
|
|
176
202
|
}
|
|
177
203
|
catch {
|
|
178
204
|
return false;
|
|
179
205
|
}
|
|
206
|
+
// `new URL('http://[::1]').hostname` is "[::1]" in Node, so both spellings are accepted.
|
|
207
|
+
if (host === 'localhost' || host === '::1' || host === '[::1]')
|
|
208
|
+
return true;
|
|
209
|
+
if (host.endsWith('.localhost') || host.endsWith('.local'))
|
|
210
|
+
return true;
|
|
211
|
+
const octets = ipv4(host);
|
|
212
|
+
if (!octets)
|
|
213
|
+
return false;
|
|
214
|
+
const [a, b] = octets;
|
|
215
|
+
if (a === 0 || a === 127)
|
|
216
|
+
return true; // 0.0.0.0 and the whole loopback range
|
|
217
|
+
if (a === 10)
|
|
218
|
+
return true;
|
|
219
|
+
if (a === 192 && b === 168)
|
|
220
|
+
return true;
|
|
221
|
+
return a === 172 && b >= 16 && b <= 31;
|
|
180
222
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.70.1",
|
|
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": [
|