tewip-ai 0.59.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.
|
@@ -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": [
|