tewip-ai 0.59.0 → 0.61.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.
@@ -115,8 +115,27 @@ function mentionsOf(root, file, tests) {
115
115
  return out;
116
116
  }
117
117
  /** Does anything at all in the project refer to this file? Used only to tell "unknown" from "unreachable". */
118
+ /**
119
+ * Whether anything in the project refers to this file at all.
120
+ *
121
+ * This searched for the file name *with its extension*, which is a JavaScript habit: a JS import
122
+ * often writes `./sorting.js`, and nothing else does. Python writes `from ..utils.sorting import x`,
123
+ * Java writes `import com.x.Sorting`, C# writes `using X.Sorting`. In all of them the extension
124
+ * never appears, so the search found nothing and the file was taken to be referenced nowhere.
125
+ *
126
+ * That is the most dangerous answer this file can produce. "Nothing refers to it" is the one
127
+ * condition that lets selection proceed past a source file no test imports - and on saleor it let
128
+ * `pick` narrow 1,229 test files down to **one**, skipping every sorting test in the suite, for a
129
+ * change to the GraphQL sorting code that all of them run through. The command's own promise is to
130
+ * refuse to narrow when it cannot show the rest are unaffected, and here it could not.
131
+ *
132
+ * The extension comes off. A name like `utils` or `index` will now match half the repository, and
133
+ * that is the right direction to be wrong in: over-matching means "this is referenced", which stops
134
+ * the narrowing. Under-matching skips the tests that would have caught the change.
135
+ */
118
136
  function mentionedAnywhere(root, file) {
119
- const named = file.split('/').pop() ?? '';
137
+ const base = file.split('/').pop() ?? '';
138
+ const named = base.replace(/\.[A-Za-z0-9]+$/, '') || base;
120
139
  const where = spawnSync('git', ['grep', '-l', '--', named], { cwd: root, encoding: 'utf8', timeout: 60_000 });
121
140
  const hits = (where.stdout ?? '')
122
141
  .split(String.fromCharCode(10))
@@ -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.59.0",
3
+ "version": "0.61.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": [