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 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 # secrets + npm audit / pip-audit / osv-scanner, triaged
173
- tewip secure --secrets-only # a fast check before you commit
174
- tewip secure --gate # fails a build only on what needs a person now
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**. Four defects that corpus exposed are fixed; the four still
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 secure` | Runs your dependency and secret scanners, then says which findings need someone now and which are noise or unreachable | Before a release, or in CI |
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
- tewip secure [--secrets-only] [--gate] [--out security.md]
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
- if (!planFile || !project)
147
- throw new Error('usage: tewip jira <plan.json> --project PROJ [--issue PROJ-123] [--type "Test"] [--only QS-001,QS-002] [--create]');
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
- projectKey: project,
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
- // A web scan is a different kind of check and needs a running app; it is not pretended here.
69
- notChecked.push('a running application (no web scan is part of this command yet)');
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);
@@ -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.7.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",