tewip-ai 0.6.0 → 0.7.1

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