tewip-ai 0.7.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 +13 -4
- package/TEAM-GUIDE.md +1 -1
- package/dist/cli/tewip.js +9 -1
- 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/package.json +1 -1
package/README.md
CHANGED
|
@@ -169,9 +169,10 @@ own metric is still open.
|
|
|
169
169
|
## Security: which findings actually matter (Phase 9, new)
|
|
170
170
|
|
|
171
171
|
```bash
|
|
172
|
-
tewip secure
|
|
173
|
-
tewip secure --
|
|
174
|
-
tewip secure --
|
|
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
|
|
175
176
|
```
|
|
176
177
|
|
|
177
178
|
Scanners are good at finding things and bad at saying which of them matter. Tewip runs the ones your
|
|
@@ -181,8 +182,16 @@ from a README), or **accepted** (a person already judged it, in a file only peop
|
|
|
181
182
|
verdict carries the evidence. No model is called, so it costs nothing, and **a credential is
|
|
182
183
|
located, never printed**.
|
|
183
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
|
+
|
|
184
191
|
Measured on the gitleaks project's own labelled samples, for the credential kinds Tewip covers:
|
|
185
|
-
**50 of 54 real credentials found**.
|
|
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
|
|
186
195
|
missed are things that are not credentials (an unsigned token, a Stripe *test* key). It never claims
|
|
187
196
|
a project is secure - it prints what it checked and what it did not.
|
|
188
197
|
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -121,7 +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` | 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 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
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 |
|
|
126
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 |
|
|
127
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 |
|
package/dist/cli/tewip.js
CHANGED
|
@@ -38,11 +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 [--secrets-only] [--gate] [--out security.md]
|
|
41
|
+
tewip secure [--url http://localhost:3000] [--secrets-only] [--gate] [--out security.md]
|
|
42
42
|
Runs the scanners this project already has (npm audit,
|
|
43
43
|
pip-audit, osv-scanner) plus Tewip's own secret scan, then says
|
|
44
44
|
which findings need someone now and which are noise or not
|
|
45
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.
|
|
46
51
|
tewip spec "<feature>" [--diff main...HEAD] [--url <page> ...] [--project PROJ] [--create]
|
|
47
52
|
An epic, its user stories and their acceptance criteria, from
|
|
48
53
|
this project's code, tests and failure history. Anything it
|
|
@@ -163,6 +168,9 @@ async function main() {
|
|
|
163
168
|
out: flag(args, 'out'),
|
|
164
169
|
secretsOnly: args.includes('--secrets-only'),
|
|
165
170
|
gate: args.includes('--gate'),
|
|
171
|
+
url: flag(args, 'url'),
|
|
172
|
+
authorised: args.includes('--i-am-authorised'),
|
|
173
|
+
zapReport: flag(args, 'zap-report'),
|
|
166
174
|
});
|
|
167
175
|
// The gate fails a build only for what Tewip decided a person must act on now.
|
|
168
176
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.7.
|
|
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",
|