tewip-ai 0.58.0 → 0.60.0
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.
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
// div that only answers to a mouse. The ones that need a rendered page (contrast, focus order,
|
|
7
7
|
// whether alt text is any good) are not attempted here and are listed in NOT_CHECKED instead.
|
|
8
8
|
import { inside, labelledBy } from './markup.js';
|
|
9
|
-
import { ARIA, INTERACTIVE, UNLABELLED_TYPES, at, clickHandler, decorative, hiddenFromEveryone, keyHandler, notRendered, show, tabIndexOf } from './element.js';
|
|
9
|
+
import { ARIA, INTERACTIVE, UNLABELLED_TYPES, at, clickHandler, customElement, decorative, hiddenFromEveryone, keyHandler, notRendered, show, tabIndexOf } from './element.js';
|
|
10
10
|
/** Checks that look at one element at a time. */
|
|
11
|
-
function perElement(t, file, labelFor, openLabels, ancestorKey, namedByComponent = new Set(), boundLabels = new Set()) {
|
|
11
|
+
function perElement(t, file, labelFor, openLabels, ancestorKey, namedByComponent = new Set(), boundLabels = new Set(), labelledByAComponent = false) {
|
|
12
12
|
const out = [];
|
|
13
13
|
const place = at(t, file);
|
|
14
14
|
const name = labelledBy(t);
|
|
@@ -47,13 +47,15 @@ function perElement(t, file, labelFor, openLabels, ancestorKey, namedByComponent
|
|
|
47
47
|
const bound = ['asp-for', 'formControlName', 'v-model', 'data-valmsg-for'].some((key) => typeof t.attrs[key] === 'string' && boundLabels.has(`${key}=${t.attrs[key]}`));
|
|
48
48
|
const linked = bound || (typeof t.attrs.id === 'string' && labelFor.has(t.attrs.id));
|
|
49
49
|
if (!name.named && !linked && openLabels === 0) {
|
|
50
|
-
if (name.unknown || t.dynamic.has('id') || t.dynamic.has('style') || (typeof t.attrs.id === 'string' && namedByComponent.has(t.attrs.id))) {
|
|
50
|
+
if (labelledByAComponent || name.unknown || t.dynamic.has('id') || t.dynamic.has('style') || (typeof t.attrs.id === 'string' && namedByComponent.has(t.attrs.id))) {
|
|
51
51
|
out.push({ rule: nameRule, confidence: 'ask-a-person', ...place,
|
|
52
|
-
why:
|
|
53
|
-
?
|
|
54
|
-
: t.
|
|
55
|
-
?
|
|
56
|
-
: '
|
|
52
|
+
why: labelledByAComponent
|
|
53
|
+
? 'no label is written on this control, but it sits inside a custom element that contains a label - a component that binds the two when it renders, which this file cannot show'
|
|
54
|
+
: typeof t.attrs.id === 'string' && namedByComponent.has(t.attrs.id)
|
|
55
|
+
? `no <label for> is written here, but a component in this file names id="${t.attrs.id}" in one of its props, so the label may be rendered there`
|
|
56
|
+
: t.dynamic.has('style')
|
|
57
|
+
? 'no label can be read here, and its style is an expression, so whether this is displayed at all cannot be read from the markup'
|
|
58
|
+
: 'no label, aria-label or wrapping <label> can be read here, and part of the element is an expression',
|
|
57
59
|
next: 'confirm the field is labelled at runtime; if not, add aria-label or a <label for>' });
|
|
58
60
|
}
|
|
59
61
|
else {
|
|
@@ -87,7 +89,11 @@ function perElement(t, file, labelFor, openLabels, ancestorKey, namedByComponent
|
|
|
87
89
|
// world is built - a backdrop div closes on click, and Escape closes it from the dialog itself.
|
|
88
90
|
// WCAG 2.1.1 asks that the *function* be reachable from the keyboard, not that this particular
|
|
89
91
|
// div be focusable. Excalidraw's Modal.tsx is exactly this shape, and calling it a blocker is wrong.
|
|
90
|
-
|
|
92
|
+
// `!customElement(t)` as well as `!t.component`: `<mat-checkbox (click)="toggle()">` is a checkbox
|
|
93
|
+
// that Angular renders with full keyboard handling, and `<mat-list-item>` likewise. Reading the
|
|
94
|
+
// markup alone they look like bare tags carrying a click. A `<tr (click)>` beside them in the same
|
|
95
|
+
// file is a genuine barrier, and still reported as one - table rows really are not focusable.
|
|
96
|
+
if (clickHandler(t) && !INTERACTIVE.has(t.name) && !t.component && !customElement(t) && !t.attrs.role && tabindex === null && !keyHandler(t) && !ancestorKey) {
|
|
91
97
|
out.push({ rule: 'click-without-keyboard', confidence: 'barrier', ...place,
|
|
92
98
|
why: 'it responds to a click, but it has no role, no tabindex and no key handler, so the keyboard cannot reach or fire it',
|
|
93
99
|
next: 'use a <button>, or add role="button", tabindex="0" and a key handler' });
|
|
@@ -160,7 +166,28 @@ export function checkFile(source, all, file, isDocument = false) {
|
|
|
160
166
|
let openLabels = 0;
|
|
161
167
|
let interactiveDepth = 0;
|
|
162
168
|
let lastHeading = 0;
|
|
163
|
-
/**
|
|
169
|
+
/**
|
|
170
|
+
* The elements currently open around this one: whether each handles keys, whether it is a custom
|
|
171
|
+
* element, and whether a label has appeared inside it.
|
|
172
|
+
*
|
|
173
|
+
* A custom element does its own labelling and does it somewhere else. Angular Material writes
|
|
174
|
+
*
|
|
175
|
+
* <mat-form-field>
|
|
176
|
+
* <mat-label>Title</mat-label>
|
|
177
|
+
* <input matInput type="text">
|
|
178
|
+
* </mat-form-field>
|
|
179
|
+
*
|
|
180
|
+
* and `MatFormField` generates the id on the input and the `for` on the label when it renders.
|
|
181
|
+
* The label is real; it is simply not expressed here. Reading the markup alone, Tewip called 426
|
|
182
|
+
* inputs in **the Angular team's own accessibility-focused component library** unlabelled.
|
|
183
|
+
*
|
|
184
|
+
* The same family as PatternFly's `<FormGroup fieldId>` in Phase 43 and ASP.NET's `asp-for` in
|
|
185
|
+
* Phase 45. This is the third spelling: the component wraps the label and the control together.
|
|
186
|
+
*
|
|
187
|
+
* A dash in the name is what marks a custom element - no standard HTML element has one - and the
|
|
188
|
+
* label must actually appear inside it, so an ordinary `<my-card>` around a bare input is still a
|
|
189
|
+
* barrier rather than a shrug.
|
|
190
|
+
*/
|
|
164
191
|
const open = [];
|
|
165
192
|
for (const [i, t] of all.entries()) {
|
|
166
193
|
if (t.name === 'label')
|
|
@@ -174,9 +201,15 @@ export function checkFile(source, all, file, isDocument = false) {
|
|
|
174
201
|
open.length = from;
|
|
175
202
|
continue;
|
|
176
203
|
}
|
|
177
|
-
|
|
204
|
+
// A label, or a component that is one by name - `<mat-label>`, `<ion-label>`, `<sl-label>`.
|
|
205
|
+
if (t.name === 'label' || /(^|-)labels?$/.test(t.name))
|
|
206
|
+
for (const o of open)
|
|
207
|
+
if (o.custom)
|
|
208
|
+
o.sawLabel = true;
|
|
209
|
+
const labelledByAComponent = open.some((o) => o.custom && o.sawLabel);
|
|
210
|
+
out.push(...perElement(t, file, labelFor, Math.max(openLabels - (t.name === 'label' ? 1 : 0), 0), open.some((o) => o.keys), namedByComponent, boundLabels, labelledByAComponent));
|
|
178
211
|
if (!t.selfClosing)
|
|
179
|
-
open.push({ name: t.name, keys: keyHandler(t) });
|
|
212
|
+
open.push({ name: t.name, keys: keyHandler(t), custom: t.name.includes('-'), sawLabel: false });
|
|
180
213
|
const id = t.attrs.id;
|
|
181
214
|
if (isDocument && id && !t.dynamic.has('id')) {
|
|
182
215
|
if (seenIds.has(id)) {
|
|
@@ -29,8 +29,44 @@ export const ARIA = new Set([
|
|
|
29
29
|
'aria-sort', 'aria-valuemax', 'aria-valuemin', 'aria-valuenow', 'aria-valuetext',
|
|
30
30
|
]);
|
|
31
31
|
/** A click handler, in any of the four dialects this scanner reads. */
|
|
32
|
+
/**
|
|
33
|
+
* A click handler, in any of the spellings the frameworks use.
|
|
34
|
+
*
|
|
35
|
+
* This was a list of four exact names, and two whole frameworks wrote theirs differently:
|
|
36
|
+
*
|
|
37
|
+
* - **Angular** binds events as `(click)="..."`. angular/components has **414** of them and Tewip
|
|
38
|
+
* saw none, so `click-without-keyboard` could never fire in any Angular project at all. The
|
|
39
|
+
* library reported zero, which looked like a clean result and was blindness.
|
|
40
|
+
* - **Vue** allows modifiers, so the attribute is `@click.stop` or `@click.prevent` rather than
|
|
41
|
+
* `@click`. nocodb has **455** of those.
|
|
42
|
+
*
|
|
43
|
+
* Svelte was already covered both ways - `on:click` and Svelte 5's `onclick` - which is why this
|
|
44
|
+
* only showed up when a third framework was finally pointed at.
|
|
45
|
+
*
|
|
46
|
+
* `keyHandler` below had no such problem because it matches on a substring; the trade is that this
|
|
47
|
+
* one has to name the shapes, so that `data-click-target` is not read as a handler.
|
|
48
|
+
*/
|
|
49
|
+
// Angular's `(click)="go()"` reaches here as an attribute plainly named `click`, because the
|
|
50
|
+
// attribute reader strips the parentheses - which is also why `keyHandler` below already worked for
|
|
51
|
+
// Angular and this did not: it matches on a substring and this matched on an exact name. No standard
|
|
52
|
+
// HTML attribute is called `click`, so taking the bare form costs nothing.
|
|
53
|
+
const CLICK = /^(?:on)?(?:click|dblclick)$|^(?:@|v-on:|on:)(?:click|dblclick)(?:[.|][\w.|$-]*)?$/i;
|
|
54
|
+
/**
|
|
55
|
+
* A custom element: a component in markup that does not spell its name with a capital.
|
|
56
|
+
*
|
|
57
|
+
* `t.component` catches React's `<Select>` and `<Foo.Bar>`, and misses every web component and every
|
|
58
|
+
* Angular or Ionic element - `<mat-checkbox>`, `<mat-list-item>`, `<ion-button>`. A dash in the name
|
|
59
|
+
* is what the HTML spec reserves for them, and no standard element has one.
|
|
60
|
+
*
|
|
61
|
+
* It matters wherever Tewip is about to say something certain about an element whose behaviour is
|
|
62
|
+
* written somewhere else. `<mat-checkbox (click)="x()">` is a real checkbox with real keyboard
|
|
63
|
+
* handling once Angular renders it; reading the markup alone it looks like a bare tag with a click.
|
|
64
|
+
*/
|
|
65
|
+
export function customElement(t) {
|
|
66
|
+
return t.name.includes('-');
|
|
67
|
+
}
|
|
32
68
|
export function clickHandler(t) {
|
|
33
|
-
return [
|
|
69
|
+
return [...Object.keys(t.attrs), ...t.dynamic].some((a) => CLICK.test(a));
|
|
34
70
|
}
|
|
35
71
|
export function keyHandler(t) {
|
|
36
72
|
return Object.keys(t.attrs).concat([...t.dynamic]).some((a) => /key(down|up|press)/i.test(a));
|
|
@@ -31,7 +31,11 @@ const PATTERNS = [
|
|
|
31
31
|
{ kind: 'sendgrid-key', what: 'a SendGrid key', re: /\b(SG\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,})\b/g },
|
|
32
32
|
{ kind: 'twilio-key', what: 'a Twilio key', re: /\b(SK[a-f0-9]{32})\b/g },
|
|
33
33
|
{ kind: 'digitalocean-token', what: 'a DigitalOcean token', re: /\b(dop_v1_[a-f0-9]{64})\b/g },
|
|
34
|
-
|
|
34
|
+
// The header *and* some key after it. Matching the header alone made a constant that merely
|
|
35
|
+
// names the format into a leaked key: Keycloak's PemUtils.java declares
|
|
36
|
+
// `BEGIN_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----"` so that it can recognise one, and Tewip
|
|
37
|
+
// told Red Hat they had committed two private keys. There is no key on those lines at all.
|
|
38
|
+
{ kind: 'private-key', what: 'a private key', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY(?: BLOCK)?-----[\r\n\s]+([A-Za-z0-9+/=]{20,})/g, minEntropy: 0 },
|
|
35
39
|
{ kind: 'connection-string', what: 'a database URL with a password in it', re: /\b((?:postgres|postgresql|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^:\s'"]+:([^@\s'"]{6,})@[^\s'"]+)/g },
|
|
36
40
|
{ kind: 'jwt', what: 'a signed token (JWT)', re: /\b(eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})(?![A-Za-z0-9_-])/g },
|
|
37
41
|
// A password in a web address leaks the same way a database URL does, and is easier to miss.
|
|
@@ -53,7 +57,11 @@ export function entropyOf(value) {
|
|
|
53
57
|
}
|
|
54
58
|
const PLACEHOLDER_WORDS = /example|placeholder|dummy|sample|changeme|your[-_ ]?(?:key|token|secret|api)|xxxx|redacted|fake|notreal|test[-_]?key|<[^>]+>|\$\{|\{\{/i;
|
|
55
59
|
/** Files whose whole purpose is to show the shape of a secret, not to hold one. */
|
|
56
|
-
|
|
60
|
+
// An OpenAPI schema is documentation of an API, and every credential in one is an example by
|
|
61
|
+
// construction - nocodb's swagger.json nests them three deep under "Example 1", too far for a
|
|
62
|
+
// one-line lookback to see. The file says what it is, which is a steadier signal than the shape
|
|
63
|
+
// of the JSON around the value.
|
|
64
|
+
const DOC_FILE = /(?:^|[\\/])(?:\.env\.example|\.env\.sample|\.env\.template|[^\\/]*\.(?:md|rst|txt|adoc)|[^\\/]*example[^\\/]*|[^\\/]*sample[^\\/]*|(?:swagger|openapi)[\w.-]*\.(?:json|ya?ml))$/i;
|
|
57
65
|
/**
|
|
58
66
|
* Why this match is probably not a live credential, or null when nothing says so.
|
|
59
67
|
*
|
|
@@ -63,11 +71,16 @@ const DOC_FILE = /(?:^|[\\/])(?:\.env\.example|\.env\.sample|\.env\.template|[^\
|
|
|
63
71
|
* there is, and a key pasted into an example file by accident is one of the commonest ways
|
|
64
72
|
* credentials leak. That case is now shown as a template finding instead of being dismissed.
|
|
65
73
|
*/
|
|
66
|
-
export function looksPlanted(value, line, file) {
|
|
74
|
+
export function looksPlanted(value, line, file, before = '') {
|
|
67
75
|
if (PLACEHOLDER_WORDS.test(value))
|
|
68
76
|
return 'the value itself says it is an example';
|
|
69
77
|
if (PLACEHOLDER_WORDS.test(line))
|
|
70
78
|
return 'the line around it says it is an example';
|
|
79
|
+
// JSON and YAML put the key on its own line when the value is long, and an OpenAPI schema is all
|
|
80
|
+
// long values. nocodb declares `"example":` and then the token underneath it, so reading only the
|
|
81
|
+
// token's line found nothing and the sample JWTs in its API documentation were reported as real.
|
|
82
|
+
if (/"?(?:example|sample|placeholder|default|demo)"?\s*:\s*$/i.test(before.trim()))
|
|
83
|
+
return 'the key above it says it is an example';
|
|
71
84
|
if (/(.)\1{5,}/.test(value))
|
|
72
85
|
return 'the value repeats one character, so it is a stand-in';
|
|
73
86
|
return null;
|
|
@@ -157,7 +170,7 @@ export function scanSecrets(root, files = sourceFiles(root)) {
|
|
|
157
170
|
line,
|
|
158
171
|
evidence: redact(lines[line - 1] ?? '', value),
|
|
159
172
|
entropy: Number(entropy.toFixed(2)),
|
|
160
|
-
placeholder: looksPlanted(value, lines[line - 1] ?? '', rel),
|
|
173
|
+
placeholder: looksPlanted(value, lines[line - 1] ?? '', rel, lines[line - 2] ?? ''),
|
|
161
174
|
template: inATemplate(rel),
|
|
162
175
|
});
|
|
163
176
|
}
|
|
@@ -101,24 +101,29 @@ export async function runSecure(cmd) {
|
|
|
101
101
|
}
|
|
102
102
|
// One check can change what another means: a secret in a file the application serves is not
|
|
103
103
|
// "never left the machine", whatever git says about it.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
//
|
|
105
|
+
// Assigned, not emptied and refilled. `reconcile` returns the array it was given when there is no
|
|
106
|
+
// web finding to reconcile against - which is every run without `--url`, so almost every run - and
|
|
107
|
+
// `items.length = 0` then emptied the very array the next line was about to copy back in. Every
|
|
108
|
+
// secret found was discarded between finding it and reporting it, and the report said "0 need
|
|
109
|
+
// someone now", which is what a clean repository says too.
|
|
110
|
+
//
|
|
111
|
+
// Found in Phase 51 by scanning Keycloak: 49 secrets located, 7 of them triaged as real risks, and
|
|
112
|
+
// a report that mentioned none of them.
|
|
113
|
+
let settled = reconcile([...items]);
|
|
107
114
|
// A person's decision about a finding wins over Tewip's, and says whose it was.
|
|
108
115
|
const corrections = loadVerdicts(root);
|
|
109
|
-
|
|
116
|
+
settled = settled.map((item) => {
|
|
110
117
|
const said = verdictFor('security', item.where, corrections) ?? verdictFor('security', item.title, corrections);
|
|
111
118
|
return said ? { ...item, verdict: said.verdict, why: `${saidByAPerson(said)} (Tewip had said ${item.verdict})` } : item;
|
|
112
119
|
});
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
items.sort(worstFirst);
|
|
116
|
-
const report = renderSecurity(items, checked, notChecked, root);
|
|
120
|
+
settled.sort(worstFirst);
|
|
121
|
+
const report = renderSecurity(settled, checked, notChecked, root);
|
|
117
122
|
console.log(report);
|
|
118
123
|
if (cmd.out) {
|
|
119
124
|
mkdirSync(dirname(resolve(cmd.out)), { recursive: true });
|
|
120
125
|
writeFileSync(resolve(cmd.out), `${report}\n`, 'utf8');
|
|
121
126
|
console.log(`\nWritten to ${cmd.out}`);
|
|
122
127
|
}
|
|
123
|
-
return
|
|
128
|
+
return settled;
|
|
124
129
|
}
|
|
@@ -45,6 +45,8 @@ const SECRET_SEVERITY = {
|
|
|
45
45
|
'google-api-key': 'medium',
|
|
46
46
|
'jwt': 'medium',
|
|
47
47
|
};
|
|
48
|
+
/** A path whose job is to hold test data. */
|
|
49
|
+
const IN_A_TEST = /(^|\/)(tests?|spec|specs|__tests__|testsuite|e2e|fixtures?|testdata|mocks?)(\/|$)|[._-](test|spec)\.[a-z]+$|Tests?\.(java|kt|cs|scala)$|(^|\/)(test|spec|mock|fixture)[-_][\w-]*\.[a-z]+$/i;
|
|
48
50
|
export function triageSecret(finding, root, accepted) {
|
|
49
51
|
const where = `${finding.file}:${finding.line}`;
|
|
50
52
|
const key = `secret ${where}`;
|
|
@@ -72,6 +74,21 @@ export function triageSecret(finding, root, accepted) {
|
|
|
72
74
|
next: 'check whether this was ever a working credential; if it was, rotate it and replace it here with an obvious placeholder',
|
|
73
75
|
};
|
|
74
76
|
}
|
|
77
|
+
// A credential committed on purpose, in a file whose whole job is to hold one. Keycloak's
|
|
78
|
+
// PemUtilsTest.java carries three keypairs so that it can test reading keypairs, and calling those
|
|
79
|
+
// "needs someone now" buries the one finding that might not be a fixture under six that are.
|
|
80
|
+
//
|
|
81
|
+
// Shown rather than hidden, for the same reason a template is: the commonest accident there is, is
|
|
82
|
+
// a working key pasted where a fake one belonged. It sits below the ones that need acting on now.
|
|
83
|
+
if (IN_A_TEST.test(finding.file.split(String.fromCharCode(92)).join('/'))) {
|
|
84
|
+
return {
|
|
85
|
+
...base,
|
|
86
|
+
verdict: 'not-reachable',
|
|
87
|
+
severity: 'medium',
|
|
88
|
+
why: `${finding.evidence} - this is a test file, so the value is probably a fixture put there on purpose`,
|
|
89
|
+
next: 'confirm it was never a working credential; if it was, rotate it and replace it here with a generated one',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
75
92
|
const tracked = isTracked(root, finding.file);
|
|
76
93
|
if (!tracked) {
|
|
77
94
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.60.0",
|
|
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": [
|