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 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 # 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
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**. Four defects that corpus exposed are fixed; the four still
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
- // 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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tewip-ai",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Tewip finds why each failing test failed - real bug, flaky test, environment, test data or changed selector - for any stack, from the command line, CI, or your editor.",
5
5
  "keywords": [
6
6
  "test-failures",