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: typeof t.attrs.id === 'string' && namedByComponent.has(t.attrs.id)
53
- ? `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`
54
- : t.dynamic.has('style')
55
- ? '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'
56
- : 'no label, aria-label or wrapping <label> can be read here, and part of the element is an expression',
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
- if (clickHandler(t) && !INTERACTIVE.has(t.name) && !t.component && !t.attrs.role && tabindex === null && !keyHandler(t) && !ancestorKey) {
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
- /** The elements currently open around this one, and whether each handles keys. */
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
- out.push(...perElement(t, file, labelFor, Math.max(openLabels - (t.name === 'label' ? 1 : 0), 0), open.some((o) => o.keys), namedByComponent, boundLabels));
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 ['onclick', '@click', 'v-on:click', 'on:click'].some((a) => a in t.attrs || t.dynamic.has(a));
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
- { kind: 'private-key', what: 'a private key', re: /(-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY(?: BLOCK)?-----)/g, minEntropy: 0 },
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
- const DOC_FILE = /(?:^|[\\/])(?:\.env\.example|\.env\.sample|\.env\.template|[^\\/]*\.(?:md|rst|txt|adoc)|[^\\/]*example[^\\/]*|[^\\/]*sample[^\\/]*)$/i;
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
- const joined = reconcile(items);
105
- items.length = 0;
106
- items.push(...joined);
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
- const decided = items.map((item) => {
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
- items.length = 0;
114
- items.push(...decided);
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 items;
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.58.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": [