tewip-ai 0.6.0 → 0.7.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.
- package/README.md +34 -3
- package/TEAM-GUIDE.md +7 -1
- package/dist/cli/tewip.js +26 -0
- package/dist/engine/secure/deps.js +123 -0
- package/dist/engine/secure/secrets.js +158 -0
- package/dist/engine/secure/secure-command.js +109 -0
- package/dist/engine/secure/triage.js +153 -0
- package/dist/engine/secure/web.js +180 -0
- package/dist/engine/secure/zap.js +56 -0
- package/dist/engine/work/jira.js +80 -32
- package/dist/engine/work/wiki-text.js +74 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -160,9 +160,40 @@ pixels, colours or spacing, because a design tool and a browser do not measure t
|
|
|
160
160
|
No model is called, so it costs nothing and answers the same way every time.
|
|
161
161
|
|
|
162
162
|
Measured on four real design/page pairs (a public repository's Figma exports and the pages built
|
|
163
|
-
from them): 38 checks, **0 differences reported on pages that match their design**,
|
|
164
|
-
|
|
165
|
-
|
|
163
|
+
from them): 38 checks, **0 differences reported on pages that match their design**, **11 of 11 real
|
|
164
|
+
drifts caught** (a renamed control, a dropped sentence, a word swapped inside a sentence, a region
|
|
165
|
+
that lost its markup) and **5 of 5 harmless changes ignored** (capitals, extra spaces, a button
|
|
166
|
+
implemented as a link, reordered sections). No designer has judged the output yet, so the roadmap's
|
|
167
|
+
own metric is still open.
|
|
168
|
+
|
|
169
|
+
## Security: which findings actually matter (Phase 9, new)
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
tewip secure # secrets + npm audit / pip-audit / osv-scanner, triaged
|
|
173
|
+
tewip secure --url http://localhost:3000 # also check the running app
|
|
174
|
+
tewip secure --zap-report zap.json # and triage an OWASP ZAP baseline report
|
|
175
|
+
tewip secure --gate # fails a build only on what needs a person now
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Scanners are good at finding things and bad at saying which of them matter. Tewip runs the ones your
|
|
179
|
+
project already has and sorts every finding into **act on this now**, **real but not reachable**
|
|
180
|
+
(a dev dependency, a package nothing imports, a file git does not track), **noise** (a placeholder
|
|
181
|
+
from a README), or **accepted** (a person already judged it, in a file only people write). Each
|
|
182
|
+
verdict carries the evidence. No model is called, so it costs nothing, and **a credential is
|
|
183
|
+
located, never printed**.
|
|
184
|
+
|
|
185
|
+
With `--url` it also checks the running app the way a browser would: security headers, cookie
|
|
186
|
+
flags, and the files that must never be served (.env, .git). No payloads, no fuzzing, no login
|
|
187
|
+
bypass - and **an address that is not your machine is refused** unless you say `--i-am-authorised`.
|
|
188
|
+
For the deep scan it prints the OWASP ZAP command and triages ZAP's report rather than pretending
|
|
189
|
+
to be a scanner.
|
|
190
|
+
|
|
191
|
+
Measured on the gitleaks project's own labelled samples, for the credential kinds Tewip covers:
|
|
192
|
+
**50 of 54 real credentials found**. The web check found **8 of 8 planted weaknesses** in two local
|
|
193
|
+
apps and reported **nothing** about the carefully served one; on four real public homepages its
|
|
194
|
+
report matched the headers those sites actually send, every time. Four defects that corpus exposed are fixed; the four still
|
|
195
|
+
missed are things that are not credentials (an unsigned token, a Stripe *test* key). It never claims
|
|
196
|
+
a project is secure - it prints what it checked and what it did not.
|
|
166
197
|
|
|
167
198
|
## Epics, stories and acceptance criteria (Phase 14, new)
|
|
168
199
|
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -121,6 +121,7 @@ collects environment failures in one issue, and (optionally) opens a fix pull re
|
|
|
121
121
|
| `tewip write` | Turns the plan's scenarios into real tests in your own framework, and keeps only the ones that pass repeatedly **and** fail when their check is broken on purpose | When you know what to test but not how to write it |
|
|
122
122
|
| `tewip plan --issue PROJ-123` | Plans from the Jira ticket itself: its description and acceptance criteria | "Here's the story, what should we test?" |
|
|
123
123
|
| `tewip jira plan.json --project PROJ` | Files the plan's scenarios as test-case tickets, linked to the story | Handing work to the team |
|
|
124
|
+
| `tewip secure [--url http://localhost:3000]` | Runs your dependency and secret scanners, checks the running app's headers, cookies and exposed files, then says which findings need someone now and which are noise or unreachable | Before a release, or in CI |
|
|
124
125
|
| `tewip spec "<feature>"` | An epic, its user stories and acceptance criteria, grounded in your code; files them in Jira with `--project PROJ --create` | Before the work is broken down |
|
|
125
126
|
| `tewip report report.xml --gate` | The CI command for GitLab, Jenkins, CircleCI, Azure, Bitbucket and the rest: diagnoses the run, comments on the GitLab merge request, and fails the build only on failures that need a person | Your CI is not GitHub Actions |
|
|
126
127
|
| `tewip watch --cmd "npm test"` | Runs your tests when you save a file and explains each failure straight away | While you are writing code |
|
|
@@ -136,6 +137,8 @@ Notes that save you time:
|
|
|
136
137
|
- `write` never touches your application code. It only adds new test files, and you review and
|
|
137
138
|
commit them yourself.
|
|
138
139
|
- Everything it writes locally goes to the `.tewip` folder in your project.
|
|
140
|
+
- Jira works with **Cloud, Server and Data Center**. To read tickets from a tracker anyone can read
|
|
141
|
+
(many open-source ones), `TEWIP_JIRA_URL` alone is enough - no account. Writing needs a token.
|
|
139
142
|
- `tewip jira` shows what it *would* file and creates nothing until you add `--create`. It never
|
|
140
143
|
files the same scenario twice, and never moves, assigns or closes a ticket.
|
|
141
144
|
- `tewip report` posts to Slack or Teams only when a failure needs a person (`TEWIP_SLACK_WEBHOOK`,
|
|
@@ -186,7 +189,10 @@ Notes that save you time:
|
|
|
186
189
|
design says and what the page says, side by side, so a renamed button or a dropped sentence is
|
|
187
190
|
found before review.
|
|
188
191
|
|
|
189
|
-
**Security**
|
|
192
|
+
**Security**
|
|
193
|
+
- `tewip secure` triages what the scanners find: what is committed and reachable, what is only in a
|
|
194
|
+
dev dependency, what is a placeholder in a README. A credential is never printed, only located,
|
|
195
|
+
and `.tewip/security-accepted.txt` records what your team has already judged.
|
|
190
196
|
|
|
191
197
|
Managers, architects and leads do not need to install anything: those views are produced by the CI
|
|
192
198
|
job and kept in a GitHub issue, or can be asked for through an AI assistant.
|
package/dist/cli/tewip.js
CHANGED
|
@@ -38,6 +38,16 @@ Usage:
|
|
|
38
38
|
File the plan's scenarios as test-case issues, linked to the
|
|
39
39
|
story. Shows what it would create; --create actually files
|
|
40
40
|
them, and the same scenario is never filed twice.
|
|
41
|
+
tewip secure [--url http://localhost:3000] [--secrets-only] [--gate] [--out security.md]
|
|
42
|
+
Runs the scanners this project already has (npm audit,
|
|
43
|
+
pip-audit, osv-scanner) plus Tewip's own secret scan, then says
|
|
44
|
+
which findings need someone now and which are noise or not
|
|
45
|
+
reachable. No model, no cost, and it never prints a credential.
|
|
46
|
+
--url <page> Also check the running app: security headers, cookie flags, and
|
|
47
|
+
files that must never be served (.env, .git). Ordinary requests
|
|
48
|
+
only. An address that is not this machine needs
|
|
49
|
+
--i-am-authorised.
|
|
50
|
+
--zap-report zap.json Triage the findings of an OWASP ZAP baseline scan as well.
|
|
41
51
|
tewip spec "<feature>" [--diff main...HEAD] [--url <page> ...] [--project PROJ] [--create]
|
|
42
52
|
An epic, its user stories and their acceptance criteria, from
|
|
43
53
|
this project's code, tests and failure history. Anything it
|
|
@@ -151,6 +161,22 @@ async function main() {
|
|
|
151
161
|
});
|
|
152
162
|
return;
|
|
153
163
|
}
|
|
164
|
+
case 'secure': {
|
|
165
|
+
const { runSecure } = await import('../engine/secure/secure-command.js');
|
|
166
|
+
const items = await runSecure({
|
|
167
|
+
root: process.cwd(),
|
|
168
|
+
out: flag(args, 'out'),
|
|
169
|
+
secretsOnly: args.includes('--secrets-only'),
|
|
170
|
+
gate: args.includes('--gate'),
|
|
171
|
+
url: flag(args, 'url'),
|
|
172
|
+
authorised: args.includes('--i-am-authorised'),
|
|
173
|
+
zapReport: flag(args, 'zap-report'),
|
|
174
|
+
});
|
|
175
|
+
// The gate fails a build only for what Tewip decided a person must act on now.
|
|
176
|
+
if (args.includes('--gate') && items.some((i) => i.verdict === 'real-risk'))
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
154
180
|
case 'spec': {
|
|
155
181
|
const feature = args.filter((a, i) => !a.startsWith('--') && !['--diff', '--url', '--out', '--project'].includes(args[i - 1] ?? '')).join(' ').trim();
|
|
156
182
|
if (!feature)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Phase 9: known-vulnerable dependencies. Tewip runs no scanner of its own here - the ecosystems
|
|
2
|
+
// already have good ones, and their databases are updated daily by people whose job that is. It
|
|
3
|
+
// runs whichever is available (npm, pip, or OSV for everything else), reads their output, and then
|
|
4
|
+
// does the part they do not: say which findings actually matter in this project.
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
const run = (command, cwd, timeoutMs = 5 * 60_000) => {
|
|
9
|
+
const r = spawnSync(command, { cwd, shell: true, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 40 * 1024 * 1024 });
|
|
10
|
+
return { code: r.status ?? 1, out: `${r.stdout ?? ''}`, err: `${r.stderr ?? ''}` };
|
|
11
|
+
};
|
|
12
|
+
const has = (command, cwd) => run(`${command} --version`, cwd, 30_000).code === 0;
|
|
13
|
+
const SEVERITY_ORDER = ['critical', 'high', 'moderate', 'medium', 'low', 'info'];
|
|
14
|
+
export const bySeverity = (a, b) => SEVERITY_ORDER.indexOf(a.severity.toLowerCase()) - SEVERITY_ORDER.indexOf(b.severity.toLowerCase());
|
|
15
|
+
/** `npm audit --json`, which needs no install and no account: every Node project already has it. */
|
|
16
|
+
export function npmAudit(root) {
|
|
17
|
+
if (!existsSync(join(root, 'package.json')))
|
|
18
|
+
return [];
|
|
19
|
+
const { out } = run('npm audit --json', root);
|
|
20
|
+
let report;
|
|
21
|
+
try {
|
|
22
|
+
report = JSON.parse(out);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
const findings = [];
|
|
28
|
+
for (const [name, entry] of Object.entries(report.vulnerabilities ?? {})) {
|
|
29
|
+
const via = (entry.via ?? []).find((v) => typeof v === 'object');
|
|
30
|
+
findings.push({
|
|
31
|
+
package: name,
|
|
32
|
+
version: String(entry.range ?? ''),
|
|
33
|
+
severity: String(entry.severity ?? 'unknown'),
|
|
34
|
+
id: String(via?.url?.split('/').pop() ?? via?.source ?? ''),
|
|
35
|
+
title: String(via?.title ?? `${name} has a known vulnerability`),
|
|
36
|
+
fixedIn: entry.fixAvailable && typeof entry.fixAvailable === 'object' ? String(entry.fixAvailable.version ?? '') : '',
|
|
37
|
+
source: 'npm audit',
|
|
38
|
+
dev: Boolean(entry.isDirect === false ? false : entry.dev ?? false) || Boolean(entry.effects?.length === 0 && entry.dev),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return findings;
|
|
42
|
+
}
|
|
43
|
+
/** `pip-audit`, when the project is Python and the tool is installed. */
|
|
44
|
+
export function pipAudit(root) {
|
|
45
|
+
const python = ['requirements.txt', 'pyproject.toml', 'setup.py'].some((f) => existsSync(join(root, f)));
|
|
46
|
+
if (!python || !has('pip-audit', root))
|
|
47
|
+
return [];
|
|
48
|
+
const { out } = run('pip-audit -f json --progress-spinner off', root);
|
|
49
|
+
try {
|
|
50
|
+
const report = JSON.parse(out);
|
|
51
|
+
const list = report.dependencies ?? report;
|
|
52
|
+
return (Array.isArray(list) ? list : []).flatMap((dep) => (dep.vulns ?? []).map((v) => ({
|
|
53
|
+
package: String(dep.name ?? ''),
|
|
54
|
+
version: String(dep.version ?? ''),
|
|
55
|
+
severity: String(v.severity ?? 'unknown').toLowerCase(),
|
|
56
|
+
id: String(v.id ?? ''),
|
|
57
|
+
title: String(v.description ?? '').split('\n')[0].slice(0, 200),
|
|
58
|
+
fixedIn: (v.fix_versions ?? []).join(', '),
|
|
59
|
+
source: 'pip-audit',
|
|
60
|
+
dev: false,
|
|
61
|
+
})));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** OSV-Scanner, which covers Go, Rust, Java, Ruby, PHP and more, when it is installed. */
|
|
68
|
+
export function osvScan(root) {
|
|
69
|
+
if (!has('osv-scanner', root))
|
|
70
|
+
return [];
|
|
71
|
+
const { out } = run(`osv-scanner --format json --recursive "${root}"`, root);
|
|
72
|
+
try {
|
|
73
|
+
const report = JSON.parse(out);
|
|
74
|
+
const findings = [];
|
|
75
|
+
for (const result of report.results ?? []) {
|
|
76
|
+
for (const pkg of result.packages ?? []) {
|
|
77
|
+
for (const v of pkg.vulnerabilities ?? []) {
|
|
78
|
+
findings.push({
|
|
79
|
+
package: String(pkg.package?.name ?? ''),
|
|
80
|
+
version: String(pkg.package?.version ?? ''),
|
|
81
|
+
severity: String(v.database_specific?.severity ?? v.severity?.[0]?.type ?? 'unknown').toLowerCase(),
|
|
82
|
+
id: String(v.id ?? ''),
|
|
83
|
+
title: String(v.summary ?? '').slice(0, 200),
|
|
84
|
+
fixedIn: '',
|
|
85
|
+
source: 'osv-scanner',
|
|
86
|
+
dev: false,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return findings;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export function auditDependencies(root) {
|
|
98
|
+
const ran = [];
|
|
99
|
+
const missing = [];
|
|
100
|
+
const findings = [];
|
|
101
|
+
if (existsSync(join(root, 'package.json'))) {
|
|
102
|
+
ran.push('npm audit');
|
|
103
|
+
findings.push(...npmAudit(root));
|
|
104
|
+
}
|
|
105
|
+
const python = ['requirements.txt', 'pyproject.toml', 'setup.py'].some((f) => existsSync(join(root, f)));
|
|
106
|
+
if (python) {
|
|
107
|
+
if (has('pip-audit', root)) {
|
|
108
|
+
ran.push('pip-audit');
|
|
109
|
+
findings.push(...pipAudit(root));
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
missing.push('pip-audit (pip install pip-audit) for the Python dependencies');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (has('osv-scanner', root)) {
|
|
116
|
+
ran.push('osv-scanner');
|
|
117
|
+
findings.push(...osvScan(root));
|
|
118
|
+
}
|
|
119
|
+
else if (!existsSync(join(root, 'package.json')) && !python) {
|
|
120
|
+
missing.push('osv-scanner (https://osv.dev) for this project\'s ecosystem');
|
|
121
|
+
}
|
|
122
|
+
return { findings: findings.sort(bySeverity), ran, missing };
|
|
123
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Phase 9: secrets left in a repository. This is the one security check Tewip does itself, because
|
|
2
|
+
// it needs no tool installed and no network: a credential either matches a shape its issuer
|
|
3
|
+
// publishes, or it does not.
|
|
4
|
+
//
|
|
5
|
+
// Two rules keep it honest. A finding must quote where it is, never what it is: the value is
|
|
6
|
+
// redacted everywhere, including in the report and in anything posted to CI. And a shape alone is
|
|
7
|
+
// not a risk - "AKIAIOSFODNN7EXAMPLE" is in Amazon's own documentation - so a match that looks like
|
|
8
|
+
// a placeholder, or that has too little randomness to be a real key, is reported as noise rather
|
|
9
|
+
// than as a leak.
|
|
10
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
11
|
+
import { join, relative, sep } from 'node:path';
|
|
12
|
+
/** Shapes their own issuers publish. Each one is a credential, not a guess about one. */
|
|
13
|
+
const PATTERNS = [
|
|
14
|
+
{ kind: 'aws-access-key-id', what: 'an AWS access key id', re: /\b((?:AKIA|ASIA|AGPA|AIDA|AROA|ANPA)[A-Z0-9]{16})\b/g, minEntropy: 2.5 },
|
|
15
|
+
{ kind: 'aws-secret-key', what: 'an AWS secret access key', re: /\baws_secret_access_key\s*[=:]\s*['"]?([A-Za-z0-9/+=]{40})\b/gi },
|
|
16
|
+
{ kind: 'github-token', what: 'a GitHub token', re: /\b((?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{60,})\b/g },
|
|
17
|
+
{ kind: 'gitlab-token', what: 'a GitLab personal access token', re: /\b(glpat-[A-Za-z0-9_-]{20,})(?![A-Za-z0-9_-])/g },
|
|
18
|
+
{ kind: 'slack-token', what: 'a Slack token', re: /\b(xox[baprs]-[A-Za-z0-9-]{10,})\b/g },
|
|
19
|
+
{ kind: 'slack-webhook', what: 'a Slack incoming webhook', re: /(https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/+]{20,})/g },
|
|
20
|
+
// Stripe writes both "live" and "prod" on real keys; a "test" key is not a credential, and is
|
|
21
|
+
// left alone on purpose. The prod form was missed until a labelled sample showed it.
|
|
22
|
+
{ kind: 'stripe-key', what: 'a live Stripe key', re: /\b((?:sk|rk)_(?:live|prod)_[A-Za-z0-9]{20,})(?![A-Za-z0-9])/g },
|
|
23
|
+
// A key may end in "-", where a word boundary never matches: a real Google key was missed that
|
|
24
|
+
// way. And "sk-ant-..." is Anthropic's, not OpenAI's, so the looser shape must exclude it or one
|
|
25
|
+
// credential gets reported twice under two names.
|
|
26
|
+
{ kind: 'openai-key', what: 'an OpenAI API key', re: /\b(sk-(?:proj-|svcacct-|admin-)?(?!ant-)[A-Za-z0-9_-]{32,})(?![A-Za-z0-9_-])/g },
|
|
27
|
+
{ kind: 'anthropic-key', what: 'an Anthropic API key', re: /\b(sk-ant-(?:api|admin)[0-9]{2}-[A-Za-z0-9_-]{32,})(?![A-Za-z0-9_-])/g },
|
|
28
|
+
{ kind: 'google-api-key', what: 'a Google API key', re: /\b(AIza[A-Za-z0-9_-]{35})(?![A-Za-z0-9_-])/g },
|
|
29
|
+
{ kind: 'npm-token', what: 'an npm token', re: /\b(npm_[A-Za-z0-9]{36})\b/g },
|
|
30
|
+
{ kind: 'pypi-token', what: 'a PyPI token', re: /\b(pypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{50,})\b/g },
|
|
31
|
+
{ kind: 'sendgrid-key', what: 'a SendGrid key', re: /\b(SG\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,})\b/g },
|
|
32
|
+
{ kind: 'twilio-key', what: 'a Twilio key', re: /\b(SK[a-f0-9]{32})\b/g },
|
|
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 },
|
|
35
|
+
{ 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
|
+
{ 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
|
+
// A password in a web address leaks the same way a database URL does, and is easier to miss.
|
|
38
|
+
{ kind: 'url-credentials', what: 'a password inside a URL', re: /\b(https?:\/\/[^:/\s'"]+:([^@\s'"]{4,})@[^\s'"]+)/g, minEntropy: 2 },
|
|
39
|
+
];
|
|
40
|
+
/** How random a value is, in bits per character. A real key is above 3; "xxxxxxxx" is 0. */
|
|
41
|
+
export function entropyOf(value) {
|
|
42
|
+
if (!value)
|
|
43
|
+
return 0;
|
|
44
|
+
const counts = new Map();
|
|
45
|
+
for (const ch of value)
|
|
46
|
+
counts.set(ch, (counts.get(ch) ?? 0) + 1);
|
|
47
|
+
let bits = 0;
|
|
48
|
+
for (const n of counts.values()) {
|
|
49
|
+
const p = n / value.length;
|
|
50
|
+
bits -= p * Math.log2(p);
|
|
51
|
+
}
|
|
52
|
+
return bits;
|
|
53
|
+
}
|
|
54
|
+
const PLACEHOLDER_WORDS = /example|placeholder|dummy|sample|changeme|your[-_ ]?(?:key|token|secret|api)|xxxx|redacted|fake|notreal|test[-_]?key|<[^>]+>|\$\{|\{\{/i;
|
|
55
|
+
/** 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;
|
|
57
|
+
/** Why this match is probably not a live credential, or null when nothing says so. */
|
|
58
|
+
export function looksPlanted(value, line, file) {
|
|
59
|
+
if (PLACEHOLDER_WORDS.test(value))
|
|
60
|
+
return 'the value itself says it is an example';
|
|
61
|
+
if (PLACEHOLDER_WORDS.test(line))
|
|
62
|
+
return 'the line around it says it is an example';
|
|
63
|
+
if (DOC_FILE.test(file))
|
|
64
|
+
return 'it is in a documentation or example file';
|
|
65
|
+
if (/(.)\1{5,}/.test(value))
|
|
66
|
+
return 'the value repeats one character, so it is a stand-in';
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const SKIP_DIR = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'coverage', 'target', 'vendor', '__pycache__', '.venv', 'venv', '.tewip', '.next', '.cache']);
|
|
70
|
+
const BINARY = /\.(?:png|jpe?g|gif|webp|ico|svg|pdf|zip|gz|tar|exe|dll|so|dylib|woff2?|ttf|eot|mp4|mp3|wav|class|jar|pyc|lock)$/i;
|
|
71
|
+
const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
72
|
+
/** Every text file in a project, skipping what no repository owns. */
|
|
73
|
+
export function sourceFiles(root, limit = 5000) {
|
|
74
|
+
const out = [];
|
|
75
|
+
const walk = (dir) => {
|
|
76
|
+
if (out.length >= limit)
|
|
77
|
+
return;
|
|
78
|
+
let entries;
|
|
79
|
+
try {
|
|
80
|
+
entries = readdirSync(dir);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
if (out.length >= limit)
|
|
87
|
+
return;
|
|
88
|
+
const path = join(dir, entry);
|
|
89
|
+
let info;
|
|
90
|
+
try {
|
|
91
|
+
info = statSync(path);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (info.isDirectory()) {
|
|
97
|
+
if (!SKIP_DIR.has(entry))
|
|
98
|
+
walk(path);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (BINARY.test(entry) || info.size > MAX_FILE_BYTES)
|
|
102
|
+
continue;
|
|
103
|
+
out.push(path);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
walk(root);
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
/** The value, blanked: first two characters, then dots. Tewip never prints a credential. */
|
|
110
|
+
export function redact(line, value) {
|
|
111
|
+
const shown = value.slice(0, 2);
|
|
112
|
+
const hidden = `${shown}${'.'.repeat(Math.min(12, Math.max(4, value.length - 2)))}`;
|
|
113
|
+
return line.replace(value, hidden).trim().slice(0, 200);
|
|
114
|
+
}
|
|
115
|
+
export function scanSecrets(root, files = sourceFiles(root)) {
|
|
116
|
+
const findings = [];
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
for (const file of files) {
|
|
119
|
+
let text;
|
|
120
|
+
try {
|
|
121
|
+
text = readFileSync(file, 'utf8');
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (text.includes('\u0000'))
|
|
127
|
+
continue; // binary after all
|
|
128
|
+
const lines = text.split('\n');
|
|
129
|
+
for (const { kind, what, re, minEntropy } of PATTERNS) {
|
|
130
|
+
for (const match of text.matchAll(re)) {
|
|
131
|
+
const value = match[1];
|
|
132
|
+
const entropy = entropyOf(value);
|
|
133
|
+
if (entropy < (minEntropy ?? 3))
|
|
134
|
+
continue;
|
|
135
|
+
const index = match.index ?? 0;
|
|
136
|
+
const line = text.slice(0, index).split('\n').length;
|
|
137
|
+
const rel = relative(root, file).split(sep).join('/');
|
|
138
|
+
// One credential, one finding: a value can match a loose shape and a tighter one, and the
|
|
139
|
+
// same key reported under two names reads as two leaks.
|
|
140
|
+
const key = `${rel}:${line}:${value}`;
|
|
141
|
+
if (seen.has(key) || seen.has(`${rel}:${line}`))
|
|
142
|
+
continue;
|
|
143
|
+
seen.add(key);
|
|
144
|
+
seen.add(`${rel}:${line}`);
|
|
145
|
+
findings.push({
|
|
146
|
+
kind,
|
|
147
|
+
what,
|
|
148
|
+
file: rel,
|
|
149
|
+
line,
|
|
150
|
+
evidence: redact(lines[line - 1] ?? '', value),
|
|
151
|
+
entropy: Number(entropy.toFixed(2)),
|
|
152
|
+
placeholder: looksPlanted(value, lines[line - 1] ?? '', rel),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return findings;
|
|
158
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// `tewip secure`: run the scanners a project already has, then say which of their findings matter.
|
|
2
|
+
//
|
|
3
|
+
// It costs nothing (no model is called), it never prints a credential, and it never claims a
|
|
4
|
+
// project is secure: it says exactly what was checked, what was not, and what it decided about each
|
|
5
|
+
// finding. "No real risk found" is a statement about these checks, not a certificate.
|
|
6
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, join, resolve } from 'node:path';
|
|
8
|
+
import { auditDependencies } from './deps.js';
|
|
9
|
+
import { scanSecrets, sourceFiles } from './secrets.js';
|
|
10
|
+
import { readAccepted, triageDependency, triageSecret, triageWeb, worstFirst } from './triage.js';
|
|
11
|
+
import { isLocal, scanWeb } from './web.js';
|
|
12
|
+
import { readZapReport, zapAvailable, zapInstructions } from './zap.js';
|
|
13
|
+
const CODE = /\.(?:[cm]?[jt]sx?|py|rb|go|java|kt|cs|php|rs|vue|svelte)$/;
|
|
14
|
+
export function renderSecurity(items, checked, notChecked, root) {
|
|
15
|
+
const real = items.filter((i) => i.verdict === 'real-risk');
|
|
16
|
+
const later = items.filter((i) => i.verdict === 'not-reachable');
|
|
17
|
+
const noise = items.filter((i) => i.verdict === 'noise');
|
|
18
|
+
const accepted = items.filter((i) => i.verdict === 'accepted');
|
|
19
|
+
const lines = ['# Tewip security check', ''];
|
|
20
|
+
lines.push(`Checked: ${checked.length > 0 ? checked.join(', ') : 'nothing ran'}.`);
|
|
21
|
+
if (notChecked.length > 0)
|
|
22
|
+
lines.push(`**Not checked**: ${notChecked.join('; ')}.`);
|
|
23
|
+
lines.push('');
|
|
24
|
+
lines.push(`${real.length} need someone now · ${later.length} real but not reachable · ${noise.length} noise · ${accepted.length} accepted earlier.`);
|
|
25
|
+
lines.push('');
|
|
26
|
+
const block = (title, group, explain) => {
|
|
27
|
+
if (group.length === 0)
|
|
28
|
+
return;
|
|
29
|
+
lines.push(`## ${title} (${group.length})`, '', explain, '');
|
|
30
|
+
for (const item of group) {
|
|
31
|
+
lines.push(`- **${item.title}** · ${item.severity}`);
|
|
32
|
+
lines.push(` - Why: ${item.why}`);
|
|
33
|
+
lines.push(` - Do: ${item.next}`);
|
|
34
|
+
lines.push(` - Found by: ${item.source}`);
|
|
35
|
+
}
|
|
36
|
+
lines.push('');
|
|
37
|
+
};
|
|
38
|
+
block('Act on these', real, 'Each one is committed or reachable, and the evidence is beside it.');
|
|
39
|
+
block('Real, but not reachable here', later, 'True of the repository, not of what it runs: fix them on a normal schedule, not tonight.');
|
|
40
|
+
block('Noise', noise, 'A shape without a credential behind it. Listed so you can see nothing was hidden.');
|
|
41
|
+
block('Accepted earlier', accepted, `From ${join('.tewip', 'security-accepted.txt')}, written by a person.`);
|
|
42
|
+
lines.push('---', '');
|
|
43
|
+
lines.push('Tewip never prints a credential: a secret is located, never quoted. To accept a finding,');
|
|
44
|
+
lines.push(`add a line to \`${join(root, '.tewip', 'security-accepted.txt')}\`, for example:`);
|
|
45
|
+
lines.push('```', 'secret tests/fixtures/sample.env:3 - a fixture, not a real key', 'dependency lodash - waiting for the upstream fix', '```');
|
|
46
|
+
lines.push('');
|
|
47
|
+
lines.push('This is not a certificate. It is the result of the checks named above, and nothing else.');
|
|
48
|
+
return lines.join('\n');
|
|
49
|
+
}
|
|
50
|
+
export async function runSecure(cmd) {
|
|
51
|
+
const root = resolve(cmd.root);
|
|
52
|
+
const accepted = readAccepted(root);
|
|
53
|
+
const files = sourceFiles(root);
|
|
54
|
+
const items = [];
|
|
55
|
+
const checked = ['secrets in the working tree'];
|
|
56
|
+
const notChecked = [];
|
|
57
|
+
for (const finding of scanSecrets(root, files))
|
|
58
|
+
items.push(triageSecret(finding, root, accepted));
|
|
59
|
+
if (!cmd.secretsOnly) {
|
|
60
|
+
const audit = auditDependencies(root);
|
|
61
|
+
checked.push(...audit.ran);
|
|
62
|
+
notChecked.push(...audit.missing);
|
|
63
|
+
const sources = files.filter((f) => CODE.test(f));
|
|
64
|
+
for (const finding of audit.findings)
|
|
65
|
+
items.push(triageDependency(finding, root, sources, accepted));
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
notChecked.push('dependencies (--secrets-only was asked for)');
|
|
69
|
+
}
|
|
70
|
+
if (cmd.url) {
|
|
71
|
+
// Checking someone else's site is their decision, not Tewip's. A local address is the person's
|
|
72
|
+
// own machine; anything else has to be said out loud.
|
|
73
|
+
if (!isLocal(cmd.url) && !cmd.authorised) {
|
|
74
|
+
throw new Error([
|
|
75
|
+
`${cmd.url} is not this machine. Tewip only checks a running site when you say you are allowed to:`,
|
|
76
|
+
` tewip secure --url ${cmd.url} --i-am-authorised`,
|
|
77
|
+
'It sends ordinary requests - no payloads, no fuzzing, no attempt to get past a login - but',
|
|
78
|
+
'scanning a system you do not run is not Tewip\'s decision to make.',
|
|
79
|
+
].join('\n'));
|
|
80
|
+
}
|
|
81
|
+
const scan = await scanWeb(cmd.url);
|
|
82
|
+
checked.push(`${cmd.url} (${scan.requests} request(s): headers, cookies, files that must not be served)`);
|
|
83
|
+
if (scan.note)
|
|
84
|
+
notChecked.push(scan.note);
|
|
85
|
+
for (const finding of scan.findings)
|
|
86
|
+
items.push(triageWeb(finding, accepted));
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
notChecked.push('a running application (pass --url http://localhost:3000 to check one)');
|
|
90
|
+
}
|
|
91
|
+
if (cmd.zapReport) {
|
|
92
|
+
const fromZap = readZapReport(resolve(cmd.zapReport));
|
|
93
|
+
checked.push(`an OWASP ZAP report (${fromZap.length} finding(s))`);
|
|
94
|
+
for (const finding of fromZap)
|
|
95
|
+
items.push(triageWeb(finding, accepted));
|
|
96
|
+
}
|
|
97
|
+
else if (cmd.url) {
|
|
98
|
+
notChecked.push(`a full web scan. Tewip's own check is passive; for the deep one: ${zapInstructions(cmd.url, zapAvailable(root))}`);
|
|
99
|
+
}
|
|
100
|
+
items.sort(worstFirst);
|
|
101
|
+
const report = renderSecurity(items, checked, notChecked, root);
|
|
102
|
+
console.log(report);
|
|
103
|
+
if (cmd.out) {
|
|
104
|
+
mkdirSync(dirname(resolve(cmd.out)), { recursive: true });
|
|
105
|
+
writeFileSync(resolve(cmd.out), `${report}\n`, 'utf8');
|
|
106
|
+
console.log(`\nWritten to ${cmd.out}`);
|
|
107
|
+
}
|
|
108
|
+
return items;
|
|
109
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Phase 9: the part scanners do not do. A scanner says "this package has a CVE" or "this looks like
|
|
2
|
+
// an AWS key"; a person then spends an afternoon deciding which of those matter. This decides, the
|
|
3
|
+
// same way the rest of Tewip decides: deterministic checks first, evidence with every verdict, and
|
|
4
|
+
// a plain next step.
|
|
5
|
+
//
|
|
6
|
+
// The verdicts are deliberately few:
|
|
7
|
+
// real-risk something a person should act on now, with the evidence that makes it real;
|
|
8
|
+
// not-reachable true of the repository, but not of what it runs: a dev-only dependency, or a
|
|
9
|
+
// package nothing imports;
|
|
10
|
+
// noise a shape without a secret behind it: documentation, a fixture, a placeholder;
|
|
11
|
+
// accepted a person already looked and said so.
|
|
12
|
+
//
|
|
13
|
+
// A secret is never printed, only located. An "accepted" verdict is never invented by Tewip - it
|
|
14
|
+
// comes from a file a person wrote.
|
|
15
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { spawnSync } from 'node:child_process';
|
|
18
|
+
/** Findings a person has already judged, one per line: "<kind> <where> - reason". */
|
|
19
|
+
export function readAccepted(root) {
|
|
20
|
+
const file = join(root, '.tewip', 'security-accepted.txt');
|
|
21
|
+
if (!existsSync(file))
|
|
22
|
+
return new Set();
|
|
23
|
+
return new Set(readFileSync(file, 'utf8')
|
|
24
|
+
.split('\n')
|
|
25
|
+
.map((l) => l.split(' - ')[0].trim())
|
|
26
|
+
.filter(Boolean));
|
|
27
|
+
}
|
|
28
|
+
/** Whether git tracks this file. A secret in an ignored file never left the machine. */
|
|
29
|
+
export function isTracked(root, file) {
|
|
30
|
+
const r = spawnSync(`git ls-files --error-unmatch "${file}"`, { cwd: root, shell: true, encoding: 'utf8', timeout: 30_000 });
|
|
31
|
+
return r.status === 0;
|
|
32
|
+
}
|
|
33
|
+
const SECRET_SEVERITY = {
|
|
34
|
+
'aws-secret-key': 'critical',
|
|
35
|
+
'aws-access-key-id': 'critical',
|
|
36
|
+
'private-key': 'critical',
|
|
37
|
+
'stripe-key': 'critical',
|
|
38
|
+
'github-token': 'high',
|
|
39
|
+
'gitlab-token': 'high',
|
|
40
|
+
'npm-token': 'high',
|
|
41
|
+
'openai-key': 'high',
|
|
42
|
+
'anthropic-key': 'high',
|
|
43
|
+
'slack-token': 'high',
|
|
44
|
+
'connection-string': 'high',
|
|
45
|
+
'google-api-key': 'medium',
|
|
46
|
+
'jwt': 'medium',
|
|
47
|
+
};
|
|
48
|
+
export function triageSecret(finding, root, accepted) {
|
|
49
|
+
const where = `${finding.file}:${finding.line}`;
|
|
50
|
+
const key = `secret ${where}`;
|
|
51
|
+
const base = {
|
|
52
|
+
kind: 'secret',
|
|
53
|
+
title: `${finding.what} in ${where}`,
|
|
54
|
+
severity: SECRET_SEVERITY[finding.kind] ?? 'medium',
|
|
55
|
+
where,
|
|
56
|
+
source: 'tewip secret scan',
|
|
57
|
+
};
|
|
58
|
+
if (accepted.has(key))
|
|
59
|
+
return { ...base, verdict: 'accepted', why: 'a person marked this one accepted in .tewip/security-accepted.txt', next: 'nothing: it was reviewed' };
|
|
60
|
+
if (finding.placeholder) {
|
|
61
|
+
return { ...base, verdict: 'noise', severity: 'low', why: `${finding.placeholder}: ${finding.evidence}`, next: 'nothing: this is not a credential' };
|
|
62
|
+
}
|
|
63
|
+
const tracked = isTracked(root, finding.file);
|
|
64
|
+
if (!tracked) {
|
|
65
|
+
return {
|
|
66
|
+
...base,
|
|
67
|
+
verdict: 'not-reachable',
|
|
68
|
+
severity: 'low',
|
|
69
|
+
why: `git does not track ${finding.file}, so this never left the machine: ${finding.evidence}`,
|
|
70
|
+
next: `keep it that way: make sure ${finding.file} stays in .gitignore`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
...base,
|
|
75
|
+
verdict: 'real-risk',
|
|
76
|
+
why: `it is committed, and the value has the randomness of a real credential (${finding.entropy} bits per character): ${finding.evidence}`,
|
|
77
|
+
next: `revoke it at the issuer first, then remove it from the file and from the history. Changing the file alone is not enough: the old value stays in every clone.`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** Does anything in this project actually import the package? */
|
|
81
|
+
export function isImported(root, pkg, sources) {
|
|
82
|
+
const name = pkg.replace(/^npm:/, '');
|
|
83
|
+
const patterns = [`from '${name}'`, `from "${name}"`, `require('${name}')`, `require("${name}")`, `import ${name}`, `import '${name}'`, `from ${name} import`];
|
|
84
|
+
for (const file of sources) {
|
|
85
|
+
let text;
|
|
86
|
+
try {
|
|
87
|
+
text = readFileSync(file, 'utf8');
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (patterns.some((p) => text.includes(p)))
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
const DEP_SEVERITY = { critical: 'critical', high: 'high', moderate: 'medium', medium: 'medium', low: 'low' };
|
|
98
|
+
export function triageDependency(finding, root, sources, accepted) {
|
|
99
|
+
const where = `${finding.package}@${finding.version}`;
|
|
100
|
+
const base = {
|
|
101
|
+
kind: 'dependency',
|
|
102
|
+
title: `${finding.title} (${where})`,
|
|
103
|
+
severity: DEP_SEVERITY[finding.severity.toLowerCase()] ?? 'medium',
|
|
104
|
+
where,
|
|
105
|
+
source: finding.source,
|
|
106
|
+
};
|
|
107
|
+
const upgrade = finding.fixedIn ? `upgrade ${finding.package} to ${finding.fixedIn}` : `no fixed version is published yet: watch ${finding.id || 'the advisory'} and consider replacing the package`;
|
|
108
|
+
if (accepted.has(`dependency ${finding.package}`) || accepted.has(`dependency ${where}`)) {
|
|
109
|
+
return { ...base, verdict: 'accepted', why: 'a person marked this one accepted in .tewip/security-accepted.txt', next: 'nothing: it was reviewed' };
|
|
110
|
+
}
|
|
111
|
+
if (finding.dev) {
|
|
112
|
+
return {
|
|
113
|
+
...base,
|
|
114
|
+
verdict: 'not-reachable',
|
|
115
|
+
severity: base.severity === 'critical' ? 'medium' : 'low',
|
|
116
|
+
why: 'it is a development dependency, so it is not in what users run',
|
|
117
|
+
next: `${upgrade} when convenient; it does not block a release`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (sources.length > 0 && !isImported(root, finding.package, sources)) {
|
|
121
|
+
return {
|
|
122
|
+
...base,
|
|
123
|
+
verdict: 'not-reachable',
|
|
124
|
+
severity: base.severity === 'critical' ? 'medium' : 'low',
|
|
125
|
+
why: `nothing in this project imports ${finding.package}: it is pulled in by another package, so the vulnerable code may never run here`,
|
|
126
|
+
next: `${upgrade}, or check which dependency requires it (npm why ${finding.package}) before spending time on it`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return { ...base, verdict: 'real-risk', why: `this project imports ${finding.package} directly, and the advisory applies to the installed version`, next: upgrade };
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* A finding about a running page. There is no "not reachable" here: the check was made against the
|
|
133
|
+
* running system, so what it saw is what a visitor sees. A person can still accept one, and a page
|
|
134
|
+
* that is only ever served inside a network is judged by severity, not by pretending it is safe.
|
|
135
|
+
*/
|
|
136
|
+
export function triageWeb(finding, accepted) {
|
|
137
|
+
const where = `${finding.url} (${finding.kind})`;
|
|
138
|
+
const base = { kind: 'web', title: finding.what, severity: finding.severity, where, source: finding.kind.startsWith('zap-') ? 'OWASP ZAP' : 'tewip baseline web check' };
|
|
139
|
+
if (accepted.has(`web ${finding.url} ${finding.kind}`) || accepted.has(`web ${finding.kind}`)) {
|
|
140
|
+
return { ...base, verdict: 'accepted', why: 'a person marked this one accepted in .tewip/security-accepted.txt', next: 'nothing: it was reviewed' };
|
|
141
|
+
}
|
|
142
|
+
// Everything here was observed in a real response, so it is real; only how much it matters varies.
|
|
143
|
+
const verdict = finding.severity === 'low' ? 'not-reachable' : 'real-risk';
|
|
144
|
+
return {
|
|
145
|
+
...base,
|
|
146
|
+
verdict,
|
|
147
|
+
why: verdict === 'real-risk' ? finding.evidence : `${finding.evidence} - worth fixing, but on its own it is not a way in`,
|
|
148
|
+
next: finding.next,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const ORDER = { 'real-risk': 0, 'not-reachable': 1, noise: 2, accepted: 3 };
|
|
152
|
+
const SEV = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
153
|
+
export const worstFirst = (a, b) => ORDER[a.verdict] - ORDER[b.verdict] || SEV[a.severity] - SEV[b.severity];
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Phase 9: the baseline check of a running application.
|
|
2
|
+
//
|
|
3
|
+
// What this is: the safe half of a web scan. Tewip asks for pages the way a browser does, reads
|
|
4
|
+
// what comes back, and reports what is verifiably missing or exposed - a cookie without Secure, a
|
|
5
|
+
// missing Content-Security-Policy, a .env file that answers with its contents. Every finding is a
|
|
6
|
+
// fact about a response, quotable in the report.
|
|
7
|
+
//
|
|
8
|
+
// What this is NOT, on purpose: no injection payloads, no fuzzing, no brute force, no attempt to
|
|
9
|
+
// get past a login, no scanning of hosts that were not named. Those need authorisation, a scope and
|
|
10
|
+
// a person - and for teams that have those, OWASP ZAP already exists, so Tewip reads ZAP's report
|
|
11
|
+
// instead of pretending to be it (see zap.ts).
|
|
12
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
13
|
+
/** A header check: what must be there, and what it protects against. */
|
|
14
|
+
const HEADERS = [
|
|
15
|
+
{
|
|
16
|
+
name: 'content-security-policy',
|
|
17
|
+
kind: 'missing-csp',
|
|
18
|
+
what: 'no Content-Security-Policy, so any injected script the page ends up with will run',
|
|
19
|
+
severity: 'high',
|
|
20
|
+
next: "add a Content-Security-Policy. Start in report-only mode with default-src 'self' and tighten it from the reports.",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'strict-transport-security',
|
|
24
|
+
kind: 'missing-hsts',
|
|
25
|
+
what: 'no Strict-Transport-Security, so a first visit over http can be intercepted',
|
|
26
|
+
severity: 'medium',
|
|
27
|
+
next: 'add Strict-Transport-Security: max-age=31536000; includeSubDomains once every subdomain is on https.',
|
|
28
|
+
httpsOnly: true,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'x-content-type-options',
|
|
32
|
+
kind: 'missing-nosniff',
|
|
33
|
+
what: 'no X-Content-Type-Options, so a browser may guess a file is script when it is not',
|
|
34
|
+
severity: 'medium',
|
|
35
|
+
next: 'add X-Content-Type-Options: nosniff.',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: 'referrer-policy',
|
|
39
|
+
kind: 'missing-referrer-policy',
|
|
40
|
+
what: 'no Referrer-Policy, so full URLs (with any ids in them) are sent to other sites',
|
|
41
|
+
severity: 'low',
|
|
42
|
+
next: 'add Referrer-Policy: strict-origin-when-cross-origin.',
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
/** Headers that tell an attacker what to attack. */
|
|
46
|
+
const TELLS = ['server', 'x-powered-by', 'x-aspnet-version', 'x-generator'];
|
|
47
|
+
const VERSIONED = /\d+\.\d+/;
|
|
48
|
+
/** Paths that must never answer with their contents. Each is a plain GET, nothing more. */
|
|
49
|
+
const EXPOSED = [
|
|
50
|
+
{ path: '/.env', kind: 'exposed-env', what: 'the .env file is served, so its settings and keys are public', severity: 'critical', looksReal: /^[A-Z][A-Z0-9_]*=/m },
|
|
51
|
+
{ path: '/.git/config', kind: 'exposed-git', what: 'the .git folder is served, so the whole source history can be downloaded', severity: 'critical', looksReal: /\[core\]|\[remote/ },
|
|
52
|
+
{ path: '/.aws/credentials', kind: 'exposed-aws', what: 'AWS credentials are served', severity: 'critical', looksReal: /aws_access_key_id/i },
|
|
53
|
+
{ path: '/config.json', kind: 'exposed-config', what: 'a configuration file is served', severity: 'medium', looksReal: /"(?:password|secret|token|apiKey)"\s*:/i },
|
|
54
|
+
];
|
|
55
|
+
const ok = (response) => response.status >= 200 && response.status < 300;
|
|
56
|
+
/** One request, with a short timeout, never following more than a couple of redirects. */
|
|
57
|
+
async function get(url, timeoutMs) {
|
|
58
|
+
try {
|
|
59
|
+
const controller = new AbortController();
|
|
60
|
+
const timer = globalThis.setTimeout(() => controller.abort(), timeoutMs);
|
|
61
|
+
const res = await fetch(url, { redirect: 'follow', signal: controller.signal, headers: { 'User-Agent': 'tewip-secure/1.0 (baseline check)' } });
|
|
62
|
+
globalThis.clearTimeout(timer);
|
|
63
|
+
const body = (await res.text()).slice(0, 4000);
|
|
64
|
+
return { status: res.status, headers: res.headers, body };
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Cookie names that usually carry a session or an identity. */
|
|
71
|
+
const SESSION_COOKIE = /sess|auth|token|login|sid$|^sid|jwt|remember|csrf|identity|account/i;
|
|
72
|
+
/** Cookies a page sets, and what protects them. */
|
|
73
|
+
function cookieFindings(headers, url) {
|
|
74
|
+
const raw = typeof headers.getSetCookie === 'function' ? headers.getSetCookie() : [headers.get('set-cookie')].filter(Boolean);
|
|
75
|
+
const findings = [];
|
|
76
|
+
for (const cookie of raw) {
|
|
77
|
+
const name = cookie.split('=')[0].trim();
|
|
78
|
+
const flags = cookie.toLowerCase();
|
|
79
|
+
const missing = [];
|
|
80
|
+
if (!flags.includes('httponly'))
|
|
81
|
+
missing.push('HttpOnly');
|
|
82
|
+
if (!flags.includes('secure') && url.startsWith('https'))
|
|
83
|
+
missing.push('Secure');
|
|
84
|
+
if (!flags.includes('samesite'))
|
|
85
|
+
missing.push('SameSite');
|
|
86
|
+
if (missing.length === 0)
|
|
87
|
+
continue;
|
|
88
|
+
// A cookie that carries a session is worth someone's evening; an analytics cookie without
|
|
89
|
+
// HttpOnly is a deliberate choice on plenty of real sites, and calling it "high" is how a
|
|
90
|
+
// security report gets ignored. The name is the only honest signal available here.
|
|
91
|
+
const session = SESSION_COOKIE.test(name);
|
|
92
|
+
findings.push({
|
|
93
|
+
kind: 'weak-cookie',
|
|
94
|
+
what: `the cookie "${name}" is set without ${missing.join(', ')}`,
|
|
95
|
+
severity: session && missing.includes('HttpOnly') ? 'high' : 'medium',
|
|
96
|
+
evidence: `Set-Cookie: ${cookie.slice(0, 120)}`,
|
|
97
|
+
url,
|
|
98
|
+
next: session
|
|
99
|
+
? `set ${missing.join(', ')} on ${name}. HttpOnly keeps script from reading it; SameSite=Lax stops it riding along on other sites' requests.`
|
|
100
|
+
: `set ${missing.join(', ')} on ${name} if it carries anything private. If it is only analytics, script may need to read it, and this is a decision rather than a fault.`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return findings;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The baseline check of one page. Nothing is sent that a browser would not send, and at most a
|
|
107
|
+
* dozen requests are made.
|
|
108
|
+
*/
|
|
109
|
+
export async function scanWeb(url, { timeoutMs = 15_000, probeExposed = true, pauseMs = 150 } = {}) {
|
|
110
|
+
const findings = [];
|
|
111
|
+
let requests = 0;
|
|
112
|
+
const page = await get(url, timeoutMs);
|
|
113
|
+
requests++;
|
|
114
|
+
if (!page)
|
|
115
|
+
return { findings, requests, note: `${url} did not answer, so nothing about it was checked.` };
|
|
116
|
+
const https = url.startsWith('https://');
|
|
117
|
+
for (const header of HEADERS) {
|
|
118
|
+
if (header.httpsOnly && !https)
|
|
119
|
+
continue;
|
|
120
|
+
if (page.headers.get(header.name))
|
|
121
|
+
continue;
|
|
122
|
+
findings.push({ kind: header.kind, what: header.what, severity: header.severity, evidence: `the response to ${url} has no ${header.name} header`, url, next: header.next });
|
|
123
|
+
}
|
|
124
|
+
// Clickjacking: either header will do, so it is one finding, not two.
|
|
125
|
+
const csp = page.headers.get('content-security-policy') ?? '';
|
|
126
|
+
if (!page.headers.get('x-frame-options') && !/frame-ancestors/i.test(csp)) {
|
|
127
|
+
findings.push({
|
|
128
|
+
kind: 'framable',
|
|
129
|
+
what: 'the page can be put in a frame on another site, which is how clickjacking works',
|
|
130
|
+
severity: 'medium',
|
|
131
|
+
evidence: `no X-Frame-Options and no frame-ancestors in the policy for ${url}`,
|
|
132
|
+
url,
|
|
133
|
+
next: "add X-Frame-Options: DENY, or frame-ancestors 'none' to the Content-Security-Policy.",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
for (const name of TELLS) {
|
|
137
|
+
const value = page.headers.get(name);
|
|
138
|
+
if (value && VERSIONED.test(value)) {
|
|
139
|
+
findings.push({
|
|
140
|
+
kind: 'version-header',
|
|
141
|
+
what: `the ${name} header names the exact software version, which tells an attacker which holes to try`,
|
|
142
|
+
severity: 'low',
|
|
143
|
+
evidence: `${name}: ${value}`,
|
|
144
|
+
url,
|
|
145
|
+
next: `stop sending ${name}, or remove the version from it.`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
findings.push(...cookieFindings(page.headers, url));
|
|
150
|
+
if (probeExposed) {
|
|
151
|
+
const base = new URL(url);
|
|
152
|
+
for (const target of EXPOSED) {
|
|
153
|
+
await delay(pauseMs);
|
|
154
|
+
const at = new URL(target.path, base.origin).href;
|
|
155
|
+
const res = await get(at, timeoutMs);
|
|
156
|
+
requests++;
|
|
157
|
+
if (!res || !ok(res) || !target.looksReal.test(res.body))
|
|
158
|
+
continue;
|
|
159
|
+
findings.push({
|
|
160
|
+
kind: target.kind,
|
|
161
|
+
what: target.what,
|
|
162
|
+
severity: target.severity,
|
|
163
|
+
evidence: `GET ${at} answered ${res.status} with the file's real contents`,
|
|
164
|
+
url: at,
|
|
165
|
+
next: `stop serving ${target.path}: it belongs outside the web root, and anything in it must be treated as leaked.`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { findings, requests, note: null };
|
|
170
|
+
}
|
|
171
|
+
/** Localhost, a private address, or a name only this machine resolves. */
|
|
172
|
+
export function isLocal(url) {
|
|
173
|
+
try {
|
|
174
|
+
const host = new URL(url).hostname;
|
|
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');
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Phase 9: OWASP ZAP. For teams that already run a real web scanner, Tewip does not compete with
|
|
2
|
+
// it - it reads its report and triages the findings, the same way it does for npm audit.
|
|
3
|
+
//
|
|
4
|
+
// ZAP's baseline scan is the one teams run in CI: it crawls passively and reports what it saw. Its
|
|
5
|
+
// JSON report is read here; Tewip does not start a scan on a host nobody named.
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
/** ZAP's risk names, as its report writes them. */
|
|
9
|
+
const RISK = { High: 'high', Medium: 'medium', Low: 'low', Informational: 'low' };
|
|
10
|
+
/** Findings from a ZAP JSON report, in Tewip's own shape. */
|
|
11
|
+
export function readZapReport(path) {
|
|
12
|
+
if (!existsSync(path))
|
|
13
|
+
return [];
|
|
14
|
+
let report;
|
|
15
|
+
try {
|
|
16
|
+
report = JSON.parse(readFileSync(path, 'utf8'));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
const alerts = (report.site ?? []).flatMap((site) => site.alerts ?? []);
|
|
22
|
+
const strip = (html) => html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
23
|
+
return alerts.map((alert) => {
|
|
24
|
+
const risk = (alert.riskdesc ?? '').split(' ')[0];
|
|
25
|
+
const first = (alert.instances ?? [])[0] ?? {};
|
|
26
|
+
return {
|
|
27
|
+
kind: `zap-${(alert.alert ?? alert.name ?? 'finding').toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 40)}`,
|
|
28
|
+
what: strip(alert.alert ?? alert.name ?? 'a finding from ZAP').slice(0, 200),
|
|
29
|
+
severity: RISK[risk] ?? 'low',
|
|
30
|
+
evidence: `${first.uri ?? ''}${first.evidence ? ` - ${strip(first.evidence).slice(0, 120)}` : ''}`.trim() || strip(alert.desc ?? '').slice(0, 200),
|
|
31
|
+
url: first.uri ?? '',
|
|
32
|
+
next: strip(alert.solution ?? '').slice(0, 300) || 'see the ZAP report for the recommended fix',
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/** Whether a ZAP baseline scan can be run from here at all. */
|
|
37
|
+
export function zapAvailable(cwd) {
|
|
38
|
+
const has = (command) => spawnSync(command, { cwd, shell: true, encoding: 'utf8', timeout: 30_000 }).status === 0;
|
|
39
|
+
if (has('zap-baseline.py --help'))
|
|
40
|
+
return 'script';
|
|
41
|
+
if (has('docker --version'))
|
|
42
|
+
return 'docker';
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* How to run ZAP against this address, for the person to copy. Tewip prints it rather than running
|
|
47
|
+
* it: a scan of a running system is the person's decision, and its report is read afterwards with
|
|
48
|
+
* `--zap-report`.
|
|
49
|
+
*/
|
|
50
|
+
export function zapInstructions(url, how) {
|
|
51
|
+
if (how === 'script')
|
|
52
|
+
return `zap-baseline.py -t ${url} -J zap.json # then: tewip secure --zap-report zap.json`;
|
|
53
|
+
if (how === 'docker')
|
|
54
|
+
return `docker run --rm -v "$PWD:/zap/wrk" -t ghcr.io/zaproxy/zaproxy zap-baseline.py -t ${url} -J zap.json # then: tewip secure --zap-report zap.json`;
|
|
55
|
+
return `OWASP ZAP is not installed here. With Docker: docker run --rm -v "$PWD:/zap/wrk" -t ghcr.io/zaproxy/zaproxy zap-baseline.py -t ${url} -J zap.json`;
|
|
56
|
+
}
|
package/dist/engine/work/jira.js
CHANGED
|
@@ -8,19 +8,26 @@
|
|
|
8
8
|
//
|
|
9
9
|
// Settings: TEWIP_JIRA_URL (https://your-team.atlassian.net), TEWIP_JIRA_EMAIL, TEWIP_JIRA_TOKEN
|
|
10
10
|
// (an API token from id.atlassian.com).
|
|
11
|
+
import { criteriaSection, wikiToText } from './wiki-text.js';
|
|
11
12
|
/** The configuration from the environment, or null when Jira is not set up. */
|
|
12
13
|
export function jiraFromEnv() {
|
|
13
14
|
const baseUrl = process.env.TEWIP_JIRA_URL?.trim().replace(/\/+$/, '');
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
if (!baseUrl)
|
|
16
|
+
return null;
|
|
17
|
+
const email = process.env.TEWIP_JIRA_EMAIL?.trim() ?? '';
|
|
18
|
+
const token = process.env.TEWIP_JIRA_TOKEN?.trim() ?? '';
|
|
19
|
+
// A tracker that anyone can read - Apache's, Jenkins', many company ones - needs no account at
|
|
20
|
+
// all, and reading a ticket to plan from it is the commonest thing Tewip does with Jira.
|
|
21
|
+
return { baseUrl, email, token };
|
|
17
22
|
}
|
|
18
23
|
export function jiraHelp() {
|
|
19
24
|
return [
|
|
20
|
-
'Jira is not set up.
|
|
21
|
-
' TEWIP_JIRA_URL=https://
|
|
25
|
+
'Jira is not set up. One setting to read a public tracker:',
|
|
26
|
+
' TEWIP_JIRA_URL=https://issues.apache.org/jira (or your own Jira, Cloud or Server)',
|
|
27
|
+
'Two more to let Tewip write test cases back into your own project:',
|
|
22
28
|
' TEWIP_JIRA_EMAIL=you@company.com',
|
|
23
|
-
' TEWIP_JIRA_TOKEN=... (
|
|
29
|
+
' TEWIP_JIRA_TOKEN=... (Cloud: id.atlassian.com/manage-profile/security/api-tokens;',
|
|
30
|
+
' Server or Data Center: your profile > Personal Access Tokens)',
|
|
24
31
|
].join('\n');
|
|
25
32
|
}
|
|
26
33
|
/** Atlassian's document format is a tree of nodes; this takes the words out of it. */
|
|
@@ -38,54 +45,95 @@ export function plainText(node) {
|
|
|
38
45
|
const FIGMA_LINK = /https:\/\/(?:www\.)?figma\.com\/[^\s)"']+/g;
|
|
39
46
|
/** Teams keep acceptance criteria under many headings; these are the common ones. */
|
|
40
47
|
const CRITERIA_HEADING = /(?:^|\n)\s*(?:acceptance criteria|ac|definition of done|dod)\s*:?\s*\n/i;
|
|
48
|
+
/**
|
|
49
|
+
* A ticket as Tewip reads it, from the raw fields. Kept apart from the client so it can be checked
|
|
50
|
+
* against real tickets without a network or an account.
|
|
51
|
+
*/
|
|
52
|
+
export function readIssue(raw) {
|
|
53
|
+
const fields = raw.fields;
|
|
54
|
+
// Jira Cloud sends a document tree; Server and Data Center send wiki markup in a string.
|
|
55
|
+
const description = (typeof fields.description === 'string' ? wikiToText(fields.description) : plainText(fields.description)).trim();
|
|
56
|
+
// Some projects have a custom field for acceptance criteria; others put a heading in the body.
|
|
57
|
+
const custom = Object.entries(fields).find(([name, value]) => /customfield/.test(name) && value && /acceptance/i.test(JSON.stringify(value).slice(0, 200)));
|
|
58
|
+
const fromHeading = criteriaSection(description);
|
|
59
|
+
return {
|
|
60
|
+
key: raw.key,
|
|
61
|
+
summary: String(fields.summary ?? ''),
|
|
62
|
+
description,
|
|
63
|
+
acceptanceCriteria: fromHeading || (custom ? plainText(custom[1]).trim() : ''),
|
|
64
|
+
type: String((fields.issuetype ?? {}).name ?? ''),
|
|
65
|
+
status: String((fields.status ?? {}).name ?? ''),
|
|
66
|
+
labels: fields.labels ?? [],
|
|
67
|
+
designLinks: [...new Set(`${description} ${JSON.stringify(fields.attachment ?? '')}`.match(FIGMA_LINK) ?? [])],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
41
70
|
export class Jira {
|
|
42
71
|
config;
|
|
72
|
+
/**
|
|
73
|
+
* Cloud speaks /rest/api/3 and wants a document tree; Server and Data Center speak /rest/api/2
|
|
74
|
+
* and want plain wiki markup. Most companies run the second kind, so both are supported: the
|
|
75
|
+
* version is guessed from the address and corrected the first time the other one answers.
|
|
76
|
+
*/
|
|
77
|
+
version;
|
|
43
78
|
constructor(config) {
|
|
44
79
|
this.config = config;
|
|
80
|
+
this.version = /atlassian\.net$/i.test(new URL(config.baseUrl).hostname) ? 3 : 2;
|
|
81
|
+
}
|
|
82
|
+
/** Whether this site turned out to be Cloud, for anything that has to send a description. */
|
|
83
|
+
get isCloud() {
|
|
84
|
+
return this.version === 3;
|
|
45
85
|
}
|
|
46
86
|
async request(method, path, body) {
|
|
47
|
-
|
|
48
|
-
|
|
87
|
+
// No credentials means an anonymous read: the header is left off entirely, because an empty
|
|
88
|
+
// one is rejected where no header at all is allowed.
|
|
89
|
+
const auth = this.config.token ? { Authorization: `Basic ${Buffer.from(`${this.config.email}:${this.config.token}`).toString('base64')}` } : {};
|
|
90
|
+
const send = async (version) => fetch(`${this.config.baseUrl}/rest/api/${version}${path}`, {
|
|
49
91
|
method,
|
|
50
|
-
headers: {
|
|
51
|
-
body: body ? JSON.stringify(body) : undefined,
|
|
92
|
+
headers: { ...auth, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
|
|
93
|
+
body: body ? JSON.stringify(this.bodyFor(version, body)) : undefined,
|
|
52
94
|
});
|
|
95
|
+
let res = await send(this.version);
|
|
96
|
+
// The wrong guess answers 404 (no such endpoint) or 410 (gone); the other version is then tried
|
|
97
|
+
// once and remembered, so a Server site does not pay for the guess on every call.
|
|
98
|
+
if ((res.status === 404 || res.status === 410) && method === 'GET') {
|
|
99
|
+
const other = this.version === 3 ? 2 : 3;
|
|
100
|
+
const second = await send(other);
|
|
101
|
+
if (second.ok) {
|
|
102
|
+
this.version = other;
|
|
103
|
+
res = second;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
53
106
|
if (!res.ok)
|
|
54
|
-
throw new Error(`Jira ${method} ${path} -> ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
107
|
+
throw new Error(`Jira ${method} /rest/api/${this.version}${path} -> ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
55
108
|
return (res.status === 204 ? undefined : await res.json());
|
|
56
109
|
}
|
|
110
|
+
/** A description is a document tree on Cloud and a string of wiki markup everywhere else. */
|
|
111
|
+
bodyFor(version, body) {
|
|
112
|
+
const fields = body.fields;
|
|
113
|
+
if (!fields || typeof fields.description !== 'string')
|
|
114
|
+
return body;
|
|
115
|
+
if (version === 2)
|
|
116
|
+
return body;
|
|
117
|
+
return { ...body, fields: { ...fields, description: { type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text: String(fields.description).slice(0, 30_000) }] }] } } };
|
|
118
|
+
}
|
|
57
119
|
/** One ticket, flattened into the facts a plan can be built from. */
|
|
58
120
|
async issue(key) {
|
|
59
|
-
const raw = await this.request('GET', `/
|
|
60
|
-
|
|
61
|
-
const description = plainText(fields.description).trim();
|
|
62
|
-
// Some projects have a custom field for acceptance criteria; others put a heading in the body.
|
|
63
|
-
const custom = Object.entries(fields).find(([name, value]) => /customfield/.test(name) && value && /acceptance/i.test(JSON.stringify(value).slice(0, 200)));
|
|
64
|
-
const fromHeading = CRITERIA_HEADING.test(description) ? description.split(CRITERIA_HEADING).slice(1).join('\n').trim() : '';
|
|
65
|
-
return {
|
|
66
|
-
key: raw.key,
|
|
67
|
-
summary: String(fields.summary ?? ''),
|
|
68
|
-
description,
|
|
69
|
-
acceptanceCriteria: fromHeading || (custom ? plainText(custom[1]).trim() : ''),
|
|
70
|
-
type: String((fields.issuetype ?? {}).name ?? ''),
|
|
71
|
-
status: String((fields.status ?? {}).name ?? ''),
|
|
72
|
-
labels: fields.labels ?? [],
|
|
73
|
-
designLinks: [...new Set(`${description} ${JSON.stringify(fields.attachment ?? '')}`.match(FIGMA_LINK) ?? [])],
|
|
74
|
-
};
|
|
121
|
+
const raw = await this.request('GET', `/issue/${encodeURIComponent(key)}`);
|
|
122
|
+
return readIssue(raw);
|
|
75
123
|
}
|
|
76
124
|
/** The issue types a project actually has, so Tewip can pick one that exists rather than guess. */
|
|
77
125
|
async issueTypes(projectKey) {
|
|
78
|
-
const meta = await this.request('GET', `/
|
|
126
|
+
const meta = await this.request('GET', `/issue/createmeta?projectKeys=${encodeURIComponent(projectKey)}&expand=projects.issuetypes`);
|
|
79
127
|
return (meta.projects?.[0]?.issuetypes ?? []).map((t) => t.name);
|
|
80
128
|
}
|
|
81
129
|
/** Creates one issue and returns its key. Nothing else about it is ever changed afterwards. */
|
|
82
130
|
async createIssue(input) {
|
|
83
|
-
const created = await this.request('POST', '/
|
|
131
|
+
const created = await this.request('POST', '/issue', {
|
|
84
132
|
fields: {
|
|
85
133
|
project: { key: input.projectKey },
|
|
86
134
|
issuetype: { name: input.type },
|
|
87
135
|
summary: input.summary.slice(0, 250),
|
|
88
|
-
description:
|
|
136
|
+
description: input.description.slice(0, 30_000),
|
|
89
137
|
...(input.labels?.length ? { labels: input.labels } : {}),
|
|
90
138
|
...(input.parentKey ? { parent: { key: input.parentKey } } : {}),
|
|
91
139
|
},
|
|
@@ -94,12 +142,12 @@ export class Jira {
|
|
|
94
142
|
}
|
|
95
143
|
/** Links a created issue to the ticket it came from, the only relationship Tewip makes. */
|
|
96
144
|
async link(fromKey, toKey, type = 'Relates') {
|
|
97
|
-
await this.request('POST', '/
|
|
145
|
+
await this.request('POST', '/issueLink', { type: { name: type }, inwardIssue: { key: fromKey }, outwardIssue: { key: toKey } });
|
|
98
146
|
}
|
|
99
147
|
/** Issues Tewip created before, so the same scenario is never filed twice. */
|
|
100
148
|
async findByLabel(projectKey, label) {
|
|
101
149
|
const jql = `project = ${projectKey} AND labels = ${label} ORDER BY created DESC`;
|
|
102
|
-
const found = await this.request('GET', `/
|
|
150
|
+
const found = await this.request('GET', `/search?jql=${encodeURIComponent(jql)}&maxResults=100&fields=summary`);
|
|
103
151
|
return (found.issues ?? []).map((i) => ({ key: i.key, summary: i.fields.summary }));
|
|
104
152
|
}
|
|
105
153
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Jira Cloud stores a description as a tree (Atlassian Document Format). Jira Server and Data
|
|
2
|
+
// Center - which is what most companies still run, and what the public Apache and Jenkins trackers
|
|
3
|
+
// use - store it as one string of wiki markup. Tewip has to read both, or half the tickets in the
|
|
4
|
+
// world come out full of `h2.`, `{code}` and `[~someone]`.
|
|
5
|
+
//
|
|
6
|
+
// Measured on 150 real tickets from those two trackers: before this, 75 of 150 descriptions came
|
|
7
|
+
// out as readable text.
|
|
8
|
+
/** A character no ticket contains, holding a heading's place while lists are converted. */
|
|
9
|
+
const MARK = String.fromCharCode(1);
|
|
10
|
+
/** A heading, so the section it starts can be found afterwards: kept as "## Words". */
|
|
11
|
+
const HEADING = /^\s*h([1-6])\.\s*(.+)$/gm;
|
|
12
|
+
/** Decorations around words: *bold*, _italic_, +inserted+, -struck-, ??cited??, ^sup^, ~sub~. */
|
|
13
|
+
const stripDecorations = (text) => text
|
|
14
|
+
.replace(/\{\{([^}]*)\}\}/g, '$1')
|
|
15
|
+
.replace(/(^|[\s(])([*_+])(?=\S)([^\n]*?\S)\2(?=[\s.,;:)!?]|$)/g, '$1$3')
|
|
16
|
+
.replace(/(^|[\s(])-(?=\S)([^\n]*?\S)-(?=[\s.,;:)!?]|$)/g, '$1$2')
|
|
17
|
+
.replace(/\?\?([^?]+)\?\?/g, '$1');
|
|
18
|
+
export function wikiToText(markup) {
|
|
19
|
+
let text = markup.replace(/\r\n?/g, '\n');
|
|
20
|
+
// Blocks first: their contents are kept, their fences are not.
|
|
21
|
+
text = text
|
|
22
|
+
.replace(/\{code(?::[^}]*)?\}\n?([\s\S]*?)\{code\}/g, (_m, body) => `\n${body.trim()}\n`)
|
|
23
|
+
.replace(/\{noformat\}\n?([\s\S]*?)\{noformat\}/g, (_m, body) => `\n${body.trim()}\n`)
|
|
24
|
+
.replace(/\{quote\}\n?([\s\S]*?)\{quote\}/g, (_m, body) => `\n${body.trim()}\n`)
|
|
25
|
+
.replace(/\{panel(?::[^}]*)?\}\n?([\s\S]*?)\{panel\}/g, (_m, body) => `\n${body.trim()}\n`)
|
|
26
|
+
.replace(/\{color(?::[^}]*)?\}/g, '')
|
|
27
|
+
.replace(/\{anchor:[^}]*\}/g, '')
|
|
28
|
+
.replace(/\{[a-z-]+(?::[^}]*)?\}/g, ''); // any other macro fence
|
|
29
|
+
// Headings become "## Words", which is how the rest of Tewip recognises a section. They are
|
|
30
|
+
// marked first and written out last: in wiki markup a numbered list item also starts with "#",
|
|
31
|
+
// and converting headings straight to "##" turned every heading into a bullet.
|
|
32
|
+
text = text.replace(HEADING, (_m, level, words) => `\n${MARK}${level} ${stripDecorations(words).trim().replace(/[:\s]+$/, '')}`);
|
|
33
|
+
text = text
|
|
34
|
+
.replace(/\[([^\]|]+)\|([^\]]+)\]/g, '$1 ($2)') // [text|https://...]
|
|
35
|
+
.replace(/\[~([\w.@-]+)\]/g, '@$1') // a person
|
|
36
|
+
.replace(/\[([^\]|]{1,80})\]/g, '$1') // [SOME-123]
|
|
37
|
+
.replace(/^\s*\|\|(.+)\|\|\s*$/gm, (_m, row) => row.split('||').map((c) => c.trim()).filter(Boolean).join(' | '))
|
|
38
|
+
.replace(/^\s*\|(.+)\|\s*$/gm, (_m, row) => row.split('|').map((c) => c.trim()).filter(Boolean).join(' | '))
|
|
39
|
+
.replace(/^\s*[*#]{1,4}\s+/gm, '- ') // bullet and numbered lists
|
|
40
|
+
.replace(/\\\\\s*$/gm, '') // a forced line break
|
|
41
|
+
.replace(/^\s*----+\s*$/gm, '');
|
|
42
|
+
// The headings, written out now that nothing else can be mistaken for them.
|
|
43
|
+
text = text.replace(new RegExp(`${MARK}([1-6]) `, 'g'), (_m, level) => `${'#'.repeat(Number(level))} `);
|
|
44
|
+
return stripDecorations(text)
|
|
45
|
+
.split('\n')
|
|
46
|
+
.map((line) => line.replace(/\s+$/, ''))
|
|
47
|
+
.join('\n')
|
|
48
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
49
|
+
.trim();
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The heading that starts the acceptance criteria. Real tickets write it as "h2. Acceptance
|
|
53
|
+
* Criteria", "h1. +*Acceptance criteria*+", "*Acceptance criteria (Given / When / Then):*",
|
|
54
|
+
* "Suggested acceptance criteria" and "Definition of Done" - all of these are from the corpus.
|
|
55
|
+
*/
|
|
56
|
+
const CRITERIA_LINE = /^\s*(?:#{1,6}\s*)?(?:suggested|proposed|draft)?\s*(?:acceptance\s+criteria|acceptance|definition\s+of\s+done|dod)\b[^\n]*$/i;
|
|
57
|
+
/** A line that starts a different section, which ends the criteria. */
|
|
58
|
+
const OTHER_HEADING = /^#{1,6}\s+\S/;
|
|
59
|
+
/** The acceptance criteria written in a description, or '' when the author wrote none. */
|
|
60
|
+
export function criteriaSection(text) {
|
|
61
|
+
const lines = text.split('\n');
|
|
62
|
+
const start = lines.findIndex((line) => CRITERIA_LINE.test(line) && /acceptance|definition\s+of\s+done|dod/i.test(line));
|
|
63
|
+
if (start < 0)
|
|
64
|
+
return '';
|
|
65
|
+
const body = [];
|
|
66
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
67
|
+
if (OTHER_HEADING.test(lines[i]))
|
|
68
|
+
break;
|
|
69
|
+
body.push(lines[i]);
|
|
70
|
+
}
|
|
71
|
+
const section = body.join('\n').trim();
|
|
72
|
+
// A heading with nothing under it is not criteria; nor is one whose "criteria" is a single word.
|
|
73
|
+
return section.length > 10 ? section : '';
|
|
74
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"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.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"test-failures",
|