tewip-ai 0.7.0 → 0.8.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.
- package/README.md +29 -4
- package/TEAM-GUIDE.md +3 -1
- package/dist/cli/tewip.js +43 -4
- package/dist/engine/secure/secure-command.js +33 -3
- package/dist/engine/secure/triage.js +20 -0
- package/dist/engine/secure/web.js +180 -0
- package/dist/engine/secure/zap.js +56 -0
- package/dist/engine/work/bug-command.js +135 -0
- package/dist/engine/work/jira-command.js +19 -0
- package/dist/engine/work/jira.js +11 -0
- package/dist/engine/work/testcase-export.js +75 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -166,12 +166,29 @@ that lost its markup) and **5 of 5 harmless changes ignored** (capitals, extra s
|
|
|
166
166
|
implemented as a link, reordered sections). No designer has judged the output yet, so the roadmap's
|
|
167
167
|
own metric is still open.
|
|
168
168
|
|
|
169
|
+
## Bugs filed back, and test cases where your team keeps them (Phase 10, completed)
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
tewip bugs report.xml --project SHOP # shows what it would file; nothing is created
|
|
173
|
+
tewip bugs report.xml --project SHOP --create # files them
|
|
174
|
+
tewip jira plan.json --export xray --out cases.csv # or --export zephyr
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`tewip bugs` files a ticket only for failures **it** diagnosed as a real bug, with the error, the
|
|
178
|
+
evidence and the run link. A flaky test or a broken environment is deliberately not filed - that is
|
|
179
|
+
the point of diagnosing first. The same failure is never filed twice: the second sighting is a short
|
|
180
|
+
note on the ticket Tewip itself opened. A dry run needs no Jira account at all.
|
|
181
|
+
|
|
182
|
+
`--export xray|zephyr` writes the plan's scenarios as the CSV those tools import, one row per step.
|
|
183
|
+
No account, no network; importing stays your step.
|
|
184
|
+
|
|
169
185
|
## Security: which findings actually matter (Phase 9, new)
|
|
170
186
|
|
|
171
187
|
```bash
|
|
172
|
-
tewip secure
|
|
173
|
-
tewip secure --
|
|
174
|
-
tewip secure --
|
|
188
|
+
tewip secure # secrets + npm audit / pip-audit / osv-scanner, triaged
|
|
189
|
+
tewip secure --url http://localhost:3000 # also check the running app
|
|
190
|
+
tewip secure --zap-report zap.json # and triage an OWASP ZAP baseline report
|
|
191
|
+
tewip secure --gate # fails a build only on what needs a person now
|
|
175
192
|
```
|
|
176
193
|
|
|
177
194
|
Scanners are good at finding things and bad at saying which of them matter. Tewip runs the ones your
|
|
@@ -181,8 +198,16 @@ from a README), or **accepted** (a person already judged it, in a file only peop
|
|
|
181
198
|
verdict carries the evidence. No model is called, so it costs nothing, and **a credential is
|
|
182
199
|
located, never printed**.
|
|
183
200
|
|
|
201
|
+
With `--url` it also checks the running app the way a browser would: security headers, cookie
|
|
202
|
+
flags, and the files that must never be served (.env, .git). No payloads, no fuzzing, no login
|
|
203
|
+
bypass - and **an address that is not your machine is refused** unless you say `--i-am-authorised`.
|
|
204
|
+
For the deep scan it prints the OWASP ZAP command and triages ZAP's report rather than pretending
|
|
205
|
+
to be a scanner.
|
|
206
|
+
|
|
184
207
|
Measured on the gitleaks project's own labelled samples, for the credential kinds Tewip covers:
|
|
185
|
-
**50 of 54 real credentials found**.
|
|
208
|
+
**50 of 54 real credentials found**. The web check found **8 of 8 planted weaknesses** in two local
|
|
209
|
+
apps and reported **nothing** about the carefully served one; on four real public homepages its
|
|
210
|
+
report matched the headers those sites actually send, every time. Four defects that corpus exposed are fixed; the four still
|
|
186
211
|
missed are things that are not credentials (an unsigned token, a Stripe *test* key). It never claims
|
|
187
212
|
a project is secure - it prints what it checked and what it did not.
|
|
188
213
|
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -121,7 +121,9 @@ 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
|
|
124
|
+
| `tewip bugs report.xml --project PROJ` | Files a bug for each failure that really is a bug, with its evidence; files nothing for flaky tests or environment problems | After a CI run with real failures |
|
|
125
|
+
| `tewip jira plan.json --export xray` | Writes the test cases as a CSV for Xray or Zephyr Scale | Your team keeps cases in a test-management tool |
|
|
126
|
+
| `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 |
|
|
125
127
|
| `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 |
|
|
126
128
|
| `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 |
|
|
127
129
|
| `tewip watch --cmd "npm test"` | Runs your tests when you save a file and explains each failure straight away | While you are writing code |
|
package/dist/cli/tewip.js
CHANGED
|
@@ -38,11 +38,23 @@ 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
|
-
|
|
41
|
+
--export xray|zephyr Write the cases as a CSV for Xray or Zephyr Scale instead.
|
|
42
|
+
No account needed; importing stays your step.
|
|
43
|
+
tewip bugs <report> --project PROJ [--create] [--max 5]
|
|
44
|
+
File a bug ticket for each failure Tewip diagnosed as a real
|
|
45
|
+
bug, with its evidence and run link. A flaky test or a broken
|
|
46
|
+
environment is deliberately not filed, and the same failure is
|
|
47
|
+
never filed twice.
|
|
48
|
+
tewip secure [--url http://localhost:3000] [--secrets-only] [--gate] [--out security.md]
|
|
42
49
|
Runs the scanners this project already has (npm audit,
|
|
43
50
|
pip-audit, osv-scanner) plus Tewip's own secret scan, then says
|
|
44
51
|
which findings need someone now and which are noise or not
|
|
45
52
|
reachable. No model, no cost, and it never prints a credential.
|
|
53
|
+
--url <page> Also check the running app: security headers, cookie flags, and
|
|
54
|
+
files that must never be served (.env, .git). Ordinary requests
|
|
55
|
+
only. An address that is not this machine needs
|
|
56
|
+
--i-am-authorised.
|
|
57
|
+
--zap-report zap.json Triage the findings of an OWASP ZAP baseline scan as well.
|
|
46
58
|
tewip spec "<feature>" [--diff main...HEAD] [--url <page> ...] [--project PROJ] [--create]
|
|
47
59
|
An epic, its user stories and their acceptance criteria, from
|
|
48
60
|
this project's code, tests and failure history. Anything it
|
|
@@ -143,12 +155,20 @@ async function main() {
|
|
|
143
155
|
case 'jira': {
|
|
144
156
|
const planFile = args.find((a) => a.endsWith('.json'));
|
|
145
157
|
const project = flag(args, 'project');
|
|
146
|
-
|
|
147
|
-
|
|
158
|
+
// Writing a file for a test-management tool needs no project at all.
|
|
159
|
+
if (!planFile || (!project && !flag(args, 'export'))) {
|
|
160
|
+
throw new Error(['usage: tewip jira <plan.json> --project PROJ [--issue PROJ-123] [--only QS-001,QS-002] [--create]', ' or: tewip jira <plan.json> --export xray|zephyr [--out cases.csv]'].join('\n'));
|
|
161
|
+
}
|
|
148
162
|
const { runJira } = await import('../engine/work/jira-command.js');
|
|
163
|
+
const exportAs = flag(args, 'export');
|
|
164
|
+
if (exportAs && exportAs !== 'xray' && exportAs !== 'zephyr')
|
|
165
|
+
throw new Error('--export takes xray or zephyr');
|
|
149
166
|
await runJira({
|
|
150
167
|
planFile,
|
|
151
|
-
|
|
168
|
+
// An export needs no project at all; filing into Jira has already been checked to have one.
|
|
169
|
+
projectKey: project ?? '',
|
|
170
|
+
exportAs: exportAs,
|
|
171
|
+
out: flag(args, 'out'),
|
|
152
172
|
type: flag(args, 'type'),
|
|
153
173
|
issueKey: flag(args, 'issue'),
|
|
154
174
|
create: args.includes('--create'),
|
|
@@ -156,6 +176,22 @@ async function main() {
|
|
|
156
176
|
});
|
|
157
177
|
return;
|
|
158
178
|
}
|
|
179
|
+
case 'bugs': {
|
|
180
|
+
const report = args.find((a, i) => !a.startsWith('--') && !['--project', '--max'].includes(args[i - 1] ?? ''));
|
|
181
|
+
const project = flag(args, 'project');
|
|
182
|
+
if (!report || !project)
|
|
183
|
+
throw new Error('usage: tewip bugs <report.xml | reports-dir | blob-report-dir> --project PROJ [--create] [--max 5]');
|
|
184
|
+
process.env.TEWIP_LLM_CACHE ??= join(process.cwd(), '.tewip', 'llm-cache.jsonl');
|
|
185
|
+
const { runBugs } = await import('../engine/work/bug-command.js');
|
|
186
|
+
await runBugs({
|
|
187
|
+
root: process.cwd(),
|
|
188
|
+
report,
|
|
189
|
+
projectKey: project,
|
|
190
|
+
create: args.includes('--create'),
|
|
191
|
+
max: Math.max(1, Number(flag(args, 'max')) || 5),
|
|
192
|
+
});
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
159
195
|
case 'secure': {
|
|
160
196
|
const { runSecure } = await import('../engine/secure/secure-command.js');
|
|
161
197
|
const items = await runSecure({
|
|
@@ -163,6 +199,9 @@ async function main() {
|
|
|
163
199
|
out: flag(args, 'out'),
|
|
164
200
|
secretsOnly: args.includes('--secrets-only'),
|
|
165
201
|
gate: args.includes('--gate'),
|
|
202
|
+
url: flag(args, 'url'),
|
|
203
|
+
authorised: args.includes('--i-am-authorised'),
|
|
204
|
+
zapReport: flag(args, 'zap-report'),
|
|
166
205
|
});
|
|
167
206
|
// The gate fails a build only for what Tewip decided a person must act on now.
|
|
168
207
|
if (args.includes('--gate') && items.some((i) => i.verdict === 'real-risk'))
|
|
@@ -7,7 +7,9 @@ import { mkdirSync, writeFileSync } from 'node:fs';
|
|
|
7
7
|
import { dirname, join, resolve } from 'node:path';
|
|
8
8
|
import { auditDependencies } from './deps.js';
|
|
9
9
|
import { scanSecrets, sourceFiles } from './secrets.js';
|
|
10
|
-
import { readAccepted, triageDependency, triageSecret, worstFirst } from './triage.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';
|
|
11
13
|
const CODE = /\.(?:[cm]?[jt]sx?|py|rb|go|java|kt|cs|php|rs|vue|svelte)$/;
|
|
12
14
|
export function renderSecurity(items, checked, notChecked, root) {
|
|
13
15
|
const real = items.filter((i) => i.verdict === 'real-risk');
|
|
@@ -65,8 +67,36 @@ export async function runSecure(cmd) {
|
|
|
65
67
|
else {
|
|
66
68
|
notChecked.push('dependencies (--secrets-only was asked for)');
|
|
67
69
|
}
|
|
68
|
-
|
|
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
|
+
}
|
|
70
100
|
items.sort(worstFirst);
|
|
71
101
|
const report = renderSecurity(items, checked, notChecked, root);
|
|
72
102
|
console.log(report);
|
|
@@ -128,6 +128,26 @@ export function triageDependency(finding, root, sources, accepted) {
|
|
|
128
128
|
}
|
|
129
129
|
return { ...base, verdict: 'real-risk', why: `this project imports ${finding.package} directly, and the advisory applies to the installed version`, next: upgrade };
|
|
130
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
|
+
}
|
|
131
151
|
const ORDER = { 'real-risk': 0, 'not-reachable': 1, noise: 2, accepted: 3 };
|
|
132
152
|
const SEV = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
133
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
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Phase 10, the third direction: a failure that really is a bug becomes a bug ticket, with the
|
|
2
|
+
// evidence that makes it one. A failure that is a flaky test or a broken environment does not -
|
|
3
|
+
// that is the whole point. Filing everything is what people already do, and it is why nobody reads
|
|
4
|
+
// the tickets.
|
|
5
|
+
//
|
|
6
|
+
// The same restraint as the rest: nothing is created without --create, the same failure is never
|
|
7
|
+
// filed twice (a second sighting is a comment on the ticket Tewip itself opened), and no ticket is
|
|
8
|
+
// ever moved, assigned or closed.
|
|
9
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
10
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
11
|
+
import { loadCorrections } from '../corrections.js';
|
|
12
|
+
import { HistoryIndex } from '../history.js';
|
|
13
|
+
import { importJunit } from '../import/junit.js';
|
|
14
|
+
import { importBlobReportDir } from '../import/blob.js';
|
|
15
|
+
import { llmFromEnv } from '../llm/client.js';
|
|
16
|
+
import { buildItems } from '../../outlets/items.js';
|
|
17
|
+
import { ciFacts } from '../ci/platform.js';
|
|
18
|
+
import { Jira, jiraFromEnv, jiraHelp } from './jira.js';
|
|
19
|
+
import { TEWIP_LABEL } from './jira-command.js';
|
|
20
|
+
/** Bugs Tewip filed carry this as well, so a second run finds them and does not file again. */
|
|
21
|
+
export const BUG_LABEL = 'tewip-bug';
|
|
22
|
+
const isBlobDir = (path) => statSync(path).isDirectory() && readdirSync(path).some((f) => /^report.*\.zip$/.test(f));
|
|
23
|
+
/** The bug report a developer actually wants: what failed, what it did, and why Tewip believes it. */
|
|
24
|
+
export function bugBody(item, runUrl) {
|
|
25
|
+
return [
|
|
26
|
+
`The test "${item.title}" failed, and Tewip diagnosed a real bug in the application rather than a problem with the test.`,
|
|
27
|
+
'',
|
|
28
|
+
`Test: ${item.testId}`,
|
|
29
|
+
`File: ${item.file}`,
|
|
30
|
+
item.commit ? `Commit: ${item.commit}` : '',
|
|
31
|
+
runUrl ? `Run: ${runUrl}` : '',
|
|
32
|
+
'',
|
|
33
|
+
'What the test saw:',
|
|
34
|
+
item.errorHead,
|
|
35
|
+
'',
|
|
36
|
+
`Diagnosis: ${item.diagnosis.category} at ${item.diagnosis.confidence.toFixed(2)} confidence (${item.source}).`,
|
|
37
|
+
'',
|
|
38
|
+
'Evidence:',
|
|
39
|
+
...item.evidence.slice(0, 6).map((line) => `- ${line}`),
|
|
40
|
+
'',
|
|
41
|
+
`Suggested next step: ${item.action.summary}`,
|
|
42
|
+
'',
|
|
43
|
+
'Filed by Tewip because this failure looks like the application being wrong, not the test. If it',
|
|
44
|
+
'is not, say so on the failure in CI (/triage CATEGORY #n reason) and Tewip will answer with your',
|
|
45
|
+
'decision next time instead of its own.',
|
|
46
|
+
]
|
|
47
|
+
.filter((line) => line !== '')
|
|
48
|
+
.join('\n');
|
|
49
|
+
}
|
|
50
|
+
/** A short line for a ticket that already exists: the same bug, seen again. */
|
|
51
|
+
export const seenAgain = (item, runUrl) => `Seen again: "${item.title}" failed in ${runUrl || 'another run'}${item.commit ? ` on ${item.commit.slice(0, 8)}` : ''}. Tewip still reads it as a real bug (${item.diagnosis.confidence.toFixed(2)}).`;
|
|
52
|
+
/**
|
|
53
|
+
* A title that stays the same across runs, so the same failure is recognised next time. The area is
|
|
54
|
+
* added only when it says something the title does not: "checkout (checkout)" helps nobody.
|
|
55
|
+
*/
|
|
56
|
+
export function bugSummary(item) {
|
|
57
|
+
const where = (item.component || item.file || '').trim();
|
|
58
|
+
const same = !where || item.title.toLowerCase().includes(where.toLowerCase());
|
|
59
|
+
return (same ? item.title : `${item.title} (${where})`).replace(/\s+/g, ' ').slice(0, 250);
|
|
60
|
+
}
|
|
61
|
+
export async function runBugs(cmd) {
|
|
62
|
+
const config = jiraFromEnv();
|
|
63
|
+
// A dry run answers the question people ask first - "what would it file?" - and needs no account
|
|
64
|
+
// at all. Only --create talks to Jira.
|
|
65
|
+
if (cmd.create && !config?.token)
|
|
66
|
+
throw new Error(`filing a bug needs a token: showing what would be filed does not.\n${jiraHelp()}`);
|
|
67
|
+
const path = resolve(cmd.report);
|
|
68
|
+
if (!existsSync(path))
|
|
69
|
+
throw new Error(`no such report: ${cmd.report}`);
|
|
70
|
+
const facts = ciFacts();
|
|
71
|
+
const name = basename(statSync(path).isDirectory() ? path : dirname(path)).replace(/[^\w.-]+/g, '-');
|
|
72
|
+
const historyDir = join(cmd.root, '.tewip', 'history', name);
|
|
73
|
+
const meta = { runId: facts.runId || `local-${Date.now()}`, gitSha: facts.sha, workspace: cmd.root };
|
|
74
|
+
const imported = isBlobDir(path) ? importBlobReportDir(path, meta, join(historyDir, 'files')) : importJunit(path, meta);
|
|
75
|
+
const index = new HistoryIndex(imported.records, [imported.summary]);
|
|
76
|
+
const items = await buildItems(imported.records, index, { dataset: `local: ${name}`, llm: llmFromEnv(), corrections: loadCorrections(historyDir) });
|
|
77
|
+
// Only what Tewip itself calls a bug, and only when it is confident enough to put a person's name
|
|
78
|
+
// on it. Everything else is deliberately left out.
|
|
79
|
+
const bugs = items.filter((i) => i.diagnosis.category === 'REAL_BUG' && i.diagnosis.confidence >= 0.8);
|
|
80
|
+
const lines = [`Report: ${cmd.report}`, `Project: ${cmd.projectKey}`, ''];
|
|
81
|
+
if (items.length === 0) {
|
|
82
|
+
console.log('Every test in that report passed. Nothing to file.');
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
if (bugs.length === 0) {
|
|
86
|
+
const kinds = [...new Set(items.map((i) => i.diagnosis.category))].join(', ');
|
|
87
|
+
console.log(`${items.length} failure(s), and none of them is a real bug Tewip is confident about (${kinds}). Nothing was filed, which is the point.`);
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
const jira = config?.token ? new Jira(config) : null;
|
|
91
|
+
let type = 'Bug';
|
|
92
|
+
let existing = [];
|
|
93
|
+
if (jira) {
|
|
94
|
+
const types = await jira.issueTypes(cmd.projectKey);
|
|
95
|
+
type = ['Bug', 'Defect', 'Task'].find((t) => types.includes(t)) ?? types[0];
|
|
96
|
+
if (!type)
|
|
97
|
+
throw new Error(`no issue type available in ${cmd.projectKey}`);
|
|
98
|
+
existing = await jira.findByLabel(cmd.projectKey, BUG_LABEL);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
lines.push('Jira was not contacted: this is what would be filed. Set TEWIP_JIRA_URL, TEWIP_JIRA_EMAIL and TEWIP_JIRA_TOKEN, then add --create.', '');
|
|
102
|
+
}
|
|
103
|
+
let filed = 0;
|
|
104
|
+
for (const item of bugs.slice(0, cmd.max)) {
|
|
105
|
+
const summary = bugSummary(item);
|
|
106
|
+
const already = existing.find((i) => i.summary === summary);
|
|
107
|
+
if (already) {
|
|
108
|
+
lines.push(` ${item.title} already filed as ${already.key}`);
|
|
109
|
+
if (cmd.create && jira) {
|
|
110
|
+
await jira.comment(already.key, seenAgain(item, facts.runUrl)).catch(() => lines.push(` (could not add the note to ${already.key})`));
|
|
111
|
+
lines.push(` added a note that it happened again`);
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (!cmd.create || !jira) {
|
|
116
|
+
lines.push(` would file: ${summary}`);
|
|
117
|
+
lines.push(` because: ${item.diagnosis.category} at ${item.diagnosis.confidence.toFixed(2)} - ${item.errorHead.replace(/\s+/g, ' ').slice(0, 100)}`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const key = await jira.createIssue({
|
|
121
|
+
projectKey: cmd.projectKey,
|
|
122
|
+
type,
|
|
123
|
+
summary,
|
|
124
|
+
description: bugBody(item, facts.runUrl),
|
|
125
|
+
labels: [TEWIP_LABEL, BUG_LABEL, `tewip-${item.component.replace(/[^\w-]+/g, '-')}`.slice(0, 40)],
|
|
126
|
+
});
|
|
127
|
+
lines.push(` filed ${key}: ${summary}`);
|
|
128
|
+
filed++;
|
|
129
|
+
}
|
|
130
|
+
const left = items.length - bugs.length;
|
|
131
|
+
lines.push('', `${bugs.length} of ${items.length} failure(s) look like real bugs; ${left} were flaky tests, environment or test data and were deliberately not filed.`);
|
|
132
|
+
lines.push(cmd.create ? `${filed} ticket(s) created. Tewip never moves, assigns or closes one.` : 'Nothing was created. Add --create to file them.');
|
|
133
|
+
console.log(lines.join('\n'));
|
|
134
|
+
return bugs;
|
|
135
|
+
}
|
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
// scenario only once: every issue it creates carries a label, so a second run finds them and skips
|
|
6
6
|
// what is already there rather than making duplicates.
|
|
7
7
|
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { dirname, resolve } from 'node:path';
|
|
8
10
|
import { Jira, jiraFromEnv, jiraHelp } from './jira.js';
|
|
11
|
+
import { exportTestCases, importHint } from './testcase-export.js';
|
|
9
12
|
export const TEWIP_LABEL = 'tewip';
|
|
10
13
|
/** One test case, written the way a person reads it rather than as JSON. */
|
|
11
14
|
export function issueBody(scenario, feature, source) {
|
|
@@ -36,9 +39,25 @@ function chosen(plan, only) {
|
|
|
36
39
|
return plan.scenarios.filter((s) => (wanted.size ? wanted.has(s.id.toUpperCase()) : !s.covered_by));
|
|
37
40
|
}
|
|
38
41
|
export async function runJira(cmd) {
|
|
42
|
+
const saved0 = JSON.parse(readFileSync(cmd.planFile, 'utf8'));
|
|
43
|
+
if (cmd.exportAs) {
|
|
44
|
+
// A file for Xray or Zephyr: no account, no network, and importing stays the person's step.
|
|
45
|
+
const chosenCases = chosen(saved0.plan, cmd.only);
|
|
46
|
+
if (chosenCases.length === 0)
|
|
47
|
+
throw new Error('every scenario in the plan is already covered by a test');
|
|
48
|
+
const csv = exportTestCases({ ...saved0.plan, scenarios: chosenCases }, saved0.feature, cmd.exportAs);
|
|
49
|
+
const file = resolve(cmd.out ?? `tewip-${cmd.exportAs}.csv`);
|
|
50
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
51
|
+
writeFileSync(file, `${csv}\n`, 'utf8');
|
|
52
|
+
console.log(`${chosenCases.length} test case(s) for ${cmd.exportAs === 'xray' ? 'Xray' : 'Zephyr Scale'}.`);
|
|
53
|
+
console.log(importHint(cmd.exportAs, file));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
39
56
|
const config = jiraFromEnv();
|
|
40
57
|
if (!config)
|
|
41
58
|
throw new Error(jiraHelp());
|
|
59
|
+
if (!config.token)
|
|
60
|
+
throw new Error(`filing test cases needs a token: reading a public tracker is free, writing to one is not.\n${jiraHelp()}`);
|
|
42
61
|
const jira = new Jira(config);
|
|
43
62
|
const saved = JSON.parse(readFileSync(cmd.planFile, 'utf8'));
|
|
44
63
|
const scenarios = chosen(saved.plan, cmd.only);
|
package/dist/engine/work/jira.js
CHANGED
|
@@ -109,6 +109,10 @@ export class Jira {
|
|
|
109
109
|
}
|
|
110
110
|
/** A description is a document tree on Cloud and a string of wiki markup everywhere else. */
|
|
111
111
|
bodyFor(version, body) {
|
|
112
|
+
const asComment = body;
|
|
113
|
+
if (typeof asComment.body === 'string') {
|
|
114
|
+
return version === 2 ? body : { body: { type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text: asComment.body.slice(0, 30_000) }] }] } };
|
|
115
|
+
}
|
|
112
116
|
const fields = body.fields;
|
|
113
117
|
if (!fields || typeof fields.description !== 'string')
|
|
114
118
|
return body;
|
|
@@ -140,6 +144,13 @@ export class Jira {
|
|
|
140
144
|
});
|
|
141
145
|
return created.key;
|
|
142
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* A note on a ticket Tewip itself opened - never on anyone else's. The caller finds the ticket by
|
|
149
|
+
* Tewip's own label, which is the only way it recognises its own work.
|
|
150
|
+
*/
|
|
151
|
+
async comment(key, text) {
|
|
152
|
+
await this.request('POST', `/issue/${encodeURIComponent(key)}/comment`, { body: text });
|
|
153
|
+
}
|
|
143
154
|
/** Links a created issue to the ticket it came from, the only relationship Tewip makes. */
|
|
144
155
|
async link(fromKey, toKey, type = 'Relates') {
|
|
145
156
|
await this.request('POST', '/issueLink', { type: { name: type }, inwardIssue: { key: fromKey }, outwardIssue: { key: toKey } });
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** One cell of a CSV, quoted the way every spreadsheet and importer expects. */
|
|
2
|
+
export function cell(value) {
|
|
3
|
+
const text = String(value ?? '').replace(/\r?\n/g, '\n');
|
|
4
|
+
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
|
5
|
+
}
|
|
6
|
+
const row = (cells) => cells.map(cell).join(',');
|
|
7
|
+
/** Xray's manual-test import: the test is repeated on every step row, as its importer expects. */
|
|
8
|
+
export function toXrayCsv(plan, feature) {
|
|
9
|
+
const header = ['Issue ID', 'Summary', 'Description', 'Test Type', 'Action', 'Data', 'Expected Result', 'Priority', 'Labels'];
|
|
10
|
+
const lines = [row(header)];
|
|
11
|
+
for (const s of plan.scenarios) {
|
|
12
|
+
const steps = s.steps.length > 0 ? s.steps : ['(see the expected result)'];
|
|
13
|
+
steps.forEach((step, index) => {
|
|
14
|
+
lines.push(row([
|
|
15
|
+
s.id,
|
|
16
|
+
index === 0 ? `${s.id} ${s.title}` : '',
|
|
17
|
+
index === 0 ? describe(s, feature) : '',
|
|
18
|
+
index === 0 ? 'Manual' : '',
|
|
19
|
+
step,
|
|
20
|
+
index === 0 ? s.preconditions : '',
|
|
21
|
+
index === steps.length - 1 ? s.expected : '',
|
|
22
|
+
index === 0 ? priority(s.priority) : '',
|
|
23
|
+
index === 0 ? `tewip ${s.type}` : '',
|
|
24
|
+
]));
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return lines.join('\n');
|
|
28
|
+
}
|
|
29
|
+
/** Zephyr Scale's test-case import, whose columns are named differently for the same idea. */
|
|
30
|
+
export function toZephyrCsv(plan, feature) {
|
|
31
|
+
const header = ['Name', 'Objective', 'Precondition', 'Priority', 'Status', 'Labels', 'Folder', 'Step', 'Test Data', 'Expected Result'];
|
|
32
|
+
const lines = [row(header)];
|
|
33
|
+
for (const s of plan.scenarios) {
|
|
34
|
+
const steps = s.steps.length > 0 ? s.steps : ['(see the expected result)'];
|
|
35
|
+
steps.forEach((step, index) => {
|
|
36
|
+
lines.push(row([
|
|
37
|
+
index === 0 ? `${s.id} ${s.title}` : '',
|
|
38
|
+
index === 0 ? describe(s, feature) : '',
|
|
39
|
+
index === 0 ? s.preconditions : '',
|
|
40
|
+
index === 0 ? priority(s.priority) : '',
|
|
41
|
+
index === 0 ? 'Draft' : '',
|
|
42
|
+
index === 0 ? `tewip,${s.type}` : '',
|
|
43
|
+
index === 0 ? `/${feature.replace(/[^\w -]+/g, '').slice(0, 60)}` : '',
|
|
44
|
+
step,
|
|
45
|
+
'',
|
|
46
|
+
index === steps.length - 1 ? s.expected : '',
|
|
47
|
+
]));
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return lines.join('\n');
|
|
51
|
+
}
|
|
52
|
+
const PRIORITY = { P0: 'High', P1: 'Medium', P2: 'Low' };
|
|
53
|
+
const priority = (p) => PRIORITY[p] ?? 'Medium';
|
|
54
|
+
/** What the case is for, and where it came from, so a reviewer can judge it. */
|
|
55
|
+
function describe(s, feature) {
|
|
56
|
+
return [
|
|
57
|
+
s.title,
|
|
58
|
+
`Feature: ${feature}`,
|
|
59
|
+
`Type: ${s.type}`,
|
|
60
|
+
s.grounded_on ? `Grounded on: ${s.grounded_on}` : '',
|
|
61
|
+
s.covered_by ? `Already covered by: ${s.covered_by}` : 'Not covered by an existing test.',
|
|
62
|
+
'Drafted by Tewip; review before you rely on it.',
|
|
63
|
+
]
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
.join('\n');
|
|
66
|
+
}
|
|
67
|
+
export function exportTestCases(plan, feature, format) {
|
|
68
|
+
return format === 'xray' ? toXrayCsv(plan, feature) : toZephyrCsv(plan, feature);
|
|
69
|
+
}
|
|
70
|
+
/** What to tell the person after writing the file, since importing is their step, not Tewip's. */
|
|
71
|
+
export function importHint(format, file) {
|
|
72
|
+
return format === 'xray'
|
|
73
|
+
? `Written to ${file}. In Jira: Xray > Import > Tests from CSV, and check the column mapping on the first screen.`
|
|
74
|
+
: `Written to ${file}. In Jira: Zephyr Scale > Test Cases > Import > CSV, and check the column mapping on the first screen.`;
|
|
75
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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",
|