diff --git a/.gitignore b/.gitignore index 3f9f440..6c6a050 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ node_modules/ dist/ .cache/ artifacts/ +# Scanner artifact writer is source, not generated report output. +!/src/artifacts/ .scanner-test-*/ *.log *.tgz diff --git a/README.md b/README.md index a593a3e..8ea1614 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ catalog:** four historical Stripe/OpenAI records; no curated changes for the oth three providers. Detection support does not imply complete API-change coverage. [Install from source](#build-from-reviewed-public-source) · [First scan](#first-scan) · -[Actual synthetic demo](#reproduce-a-synthetic-finding) · [Feedback](FEEDBACK.md) · +[Actual synthetic demo](#reproduce-a-synthetic-finding) · [HTML reports](#visual-html-reports) · [Feedback](FEEDBACK.md) · [Confidential security reporting](SECURITY.md) ## Build from reviewed public source @@ -72,6 +72,31 @@ directory; `--help` lists bounded scan/module options. Invalid input exits 1. A report exits 0 **even with partial coverage or an affected finding**; exit status is not a compatibility/security gate. Inspect `coverage.complete`, warnings and limits. +## Visual HTML reports + +Generate a self-contained local dashboard alongside the existing terminal or JSON report: + +```sh +node dist/index.js scan ./my-project --html ./report.html +node dist/index.js scan ./my-project --json --html ./report-json.html +``` + +Choose a **new `.html` file in an existing stable, non-symlink directory**. Existing +files are never overwritten, and no browser opens automatically. In Chrome, use +File → Open File to view the report. No server, account or network connection is needed. + +The dashboard prioritizes existing findings, source references, uncertainty and +manual next steps. Expand technical details for full assessment evidence, coverage, +provenance and fingerprints. Attention counts Affected + Potential risk; Unknown is +shown separately and still requires review. Affected is historical applicability, +not proven runtime failure; no known finding is not a compatibility guarantee. +HTML omits absolute target/output paths, raw arguments and source snippets; review +remaining root-relative labels and metadata privately before sharing. + +See [the report guide](docs/SCANNER_REPORTS.md) for status meanings, privacy, CSP, +output errors and residual filesystem/interrupted-write limits. Terminal/JSON output +and detection stay unchanged. No history, comparison, monitoring or repair is added. + ## Reproduce a synthetic finding After the source build, run: diff --git a/docs/SCANNER_REPORTS.md b/docs/SCANNER_REPORTS.md new file mode 100644 index 0000000..f2f12fd --- /dev/null +++ b/docs/SCANNER_REPORTS.md @@ -0,0 +1,113 @@ +# Local HTML reports + +Generate an optional self-contained report from a reviewed Scanner build. Use Node +24 and the existing Scanner dependencies. No account or API key is needed. + +```sh +node dist/index.js scan ./my-project --html ./report.html +node dist/index.js scan ./my-project --json --html ./report-with-json.html +``` + +The destination must be a **new `.html` file in an existing stable non-symlink +directory**. Use an absolute or direct relative path without `..`; quote spaces. +No directory is created automatically. The CLI never opens a browser. To preview, +use Chrome's File → Open File (Command-O on macOS) and select the report. No server +or website publication is necessary. + +Scanning remains offline and read-only. Only the explicitly selected report file +is created; prefer a private location outside the target. An explicitly selected +HTML location inside the target is allowed after scanning finishes. Source, +manifests, lockfiles and configuration are not modified. With `--json --html`, +stdout retains the existing schema-2 JSON without artifact metadata or messages. +Without `--json`, stdout retains the existing terminal report. + +## Read the result + +The dashboard uses Overview → Findings → Recommended actions → +Detected integrations → Technical details. Overview shows files, integrations, +assessments requiring attention and unknown counts. **Attention** counts existing +Affected + Potential risk assessments; Unknown is a separate count and still needs +review. These are recorded assessment counts, not incidents or a health score. +Affected is red, potential risk amber, unknown and scoped exclusions neutral. Green +is used only for “Report generated,” never compatibility. + +Finding cards keep their status, source references, important uncertainty and +relevant manual actions visible. Full existing assessment evidence and provenance +remain in native keyboard-operable `
` disclosures. Fingerprints, timestamps, +resource ceilings, full scope and long limitations are under Technical details. +Critical scan warnings and coverage/truncation indicators remain visible without +expansion. Before printing, expand the evidence sections you need and inspect print +preview; behavior in an actual browser still requires manual acceptance. + +Supported +source/manifest references and declaration versus recorded SDK evidence explain +what was found. Historical records show status, category, evidence scope, version/ +configuration evaluation, official documentation and recorded manual review dates. + +| Label | Meaning | +| --- | --- | +| Affected | Conditional applicability to this documented historical record; not confirmed runtime failure. A record can be nonbreaking. | +| Potential risk | Existing `potentially-affected` evidence: incomplete, conflicting or conditional. No new severity classification. | +| Unknown | Insufficient evidence; not a pass. | +| Not affected by this change | Exclusion from this specific change, not overall compatibility. | +| No known finding within supported coverage | No matched assessment for a detected integration, with complete recorded coverage; limited catalog scope still applies. Partial coverage is labeled unknown instead. | + +Investigate source locations, actual installed/deployed versions and dynamic +configuration. Review historical official guidance and check that a suggested +replacement is still appropriate before choosing and manually testing a migration. +Scanner generated no patch and verified no repair. Exit 0 means report generation +succeeded, including unknown, affected or partial findings; it is not a release gate. + +## Privacy and output safety + +HTML intentionally omits absolute target/output paths, raw arguments, source +snippets, client variable names, configuration values and user-supplied API-version +values. The invocation is labeled **redacted equivalent**, not an exact command. +Unsafe source labels and quoted warning details are withheld with an omission +notice. Root-relative source labels and permitted numeric SDK versions can still +be sensitive; inspect privately before sharing. This is a minimized projection, +not a complete JSON export or an assurance that arbitrary filenames are secret-free. +Normal terminal/JSON output retains its existing sensitive-data caveats. + +All displayed text is escaped. CSS is embedded with a SHA-256 CSP; scripts, images, +fonts, forms and automatic network requests are prohibited. Source locations are +inert text. Validated official documentation links leave the local report only if +you follow them manually. No telemetry, history, comparison, upload or browser +storage is added. + +Existing destinations, including symlinks/hardlinks/directories, are refused. Files +are created exclusively with mode 0600, descriptor/ancestor identity checks and +fsync. No overwrite flag exists. Portable Node filesystem checks cannot eliminate +hostile concurrent ancestor replacement. Use a stable private directory. A failed +or interrupted write can leave an incomplete file, including an empty file created +through an ancestor swapped during creation; the CLI reports failure and does not +delete a potentially replaced path. Inspect privately and choose a new filename. +This is no-clobber creation, not crash-atomic publication or filesystem sandboxing. + +Requested artifact failure exits 1 with stderr and no success stdout. Rendering +limits are 32 MiB for input string content/output, nesting 64 and 100,000 structured +nodes. Oversized optional reports fail explicitly rather than hiding findings; +ordinary terminal/JSON functionality and existing scan budgets remain available. + +## Metadata and scope + +Trusted builds embed package version and a SHA-256 fingerprint of Scanner source, +catalog, lock/package metadata and build configuration/tool inputs. The fingerprint +identifies build inputs, not the final executable bytes, authenticity or proof of a +detection-algorithm change. Source execution reports unknown version/build identity. +Finding references hash the provider/package/change and original static client ID +and manifest scope; they are deterministic for unchanged evidence, not durable +cross-scan client identities. Line/alias changes may change them. No comparison +behavior or fix detection is implemented. +Scan start/finish and HTML generation timestamps are local UTC wall-clock values, +not externally attested ordering. The catalog fingerprint hashes the same validated +snapshot used by assessments, with recursively sorted object keys and preserved +normalized array order. Catalog revision remains separate from schema version. + +Discovery supports selected Stripe, OpenAI, Anthropic, Shopify and Twilio JS/TS SDK +patterns. The bundled catalog contains four historical Stripe/OpenAI records, not +continuous intelligence. Unknown runtime behavior, unsupported patterns, unresolved +defaults, graph/assessment/display limits and static filesystem limitations remain. +Read-only local visual explanation is a free Scanner feature. Continuous provider +monitoring, advanced Impact Intelligence, AI repairs, customer-patch verification +and GitHub repair PRs remain planned Cloud capabilities and are unavailable. diff --git a/eslint.config.mjs b/eslint.config.mjs index d3d2aa6..469f616 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,7 +2,7 @@ import js from '@eslint/js'; import tseslint from 'typescript-eslint'; export default tseslint.config( - { ignores: ['dist/**', 'node_modules/**', 'apps/**', '.cache/**'] }, + { ignores: ['dist/**', 'node_modules/**', 'apps/**', '.cache/**', 'artifacts/**'] }, js.configs.recommended, ...tseslint.configs.recommended, ); diff --git a/scripts/create-pilot-package.mjs b/scripts/create-pilot-package.mjs index efb89d2..20f8bf7 100644 --- a/scripts/create-pilot-package.mjs +++ b/scripts/create-pilot-package.mjs @@ -14,6 +14,7 @@ assert.ok(process.argv.length===2||(process.argv.length===3&&publicCandidate),'U const publicTemplates=existsSync('PUBLIC_PROVENANCE.md')?'.':'packaging/public-beta'; const root = process.cwd(); const pkg = JSON.parse(await readFile('package.json', 'utf8')); +const { reportDefines } = await import('../tsup.config.ts'); assert.equal(pkg.private, true); assert.match(pkg.version, /^\d+\.\d+\.\d+$/); const cache = resolve('.cache'); @@ -27,7 +28,7 @@ const expected = [ 'dist/index.js', 'licenses/semver.LICENSE', 'licenses/typescript.LICENSE.txt', 'licenses/typescript.ThirdPartyNoticeText.txt', 'package.json', ] ; -if(publicCandidate)expected.push('SECURITY.md','CONTRIBUTING.md','FEEDBACK.md','PUBLIC_PROVENANCE.md'); +if(publicCandidate)expected.push('SECURITY.md','CONTRIBUTING.md','FEEDBACK.md','PUBLIC_PROVENANCE.md','docs/SCANNER_REPORTS.md'); expected.sort(); try { await mkdir(resolve(staging, 'licenses')); @@ -35,6 +36,7 @@ try { entry: ['src/cli/index.ts'], outDir: resolve(staging, 'dist'), format: ['esm'], platform: 'node', target: 'node24', clean: true, sourcemap: false, noExternal: ['typescript', 'semver'], config: false, + define: reportDefines(), banner: { js: "import { createRequire as __patchwrkCreateRequire } from 'node:module'; const require = __patchwrkCreateRequire(import.meta.url); const __filename = import.meta.filename; const __dirname = import.meta.dirname;" }, esbuildOptions(options) { options.metafile = true; @@ -63,6 +65,10 @@ try { await copyFile('LICENSE', resolve(staging, 'LICENSE')); await copyFile(publicCandidate?resolve(publicTemplates,'README.md'):'packaging/pilot/README.md', resolve(staging, 'README.md')); if(publicCandidate)for(const file of ['SECURITY.md','CONTRIBUTING.md','FEEDBACK.md','PUBLIC_PROVENANCE.md'])await copyFile(resolve(publicTemplates,file),resolve(staging,file)); + if(publicCandidate) { + await mkdir(resolve(staging, 'docs')); + await copyFile(publicTemplates === '.' ? 'docs/SCANNER_REPORTS.md' : resolve(publicTemplates, 'SCANNER_REPORTS.md'), resolve(staging, 'docs/SCANNER_REPORTS.md')); + } const notices = ['Bundled runtime dependencies (not registry dependencies):']; for (const [name, files] of [['typescript', ['LICENSE.txt', 'ThirdPartyNoticeText.txt']], ['semver', ['LICENSE']]]) { const base = dirname(require.resolve(name + '/package.json')); diff --git a/scripts/verify-pilot-package.mjs b/scripts/verify-pilot-package.mjs index 69859ee..fff1881 100644 --- a/scripts/verify-pilot-package.mjs +++ b/scripts/verify-pilot-package.mjs @@ -23,7 +23,7 @@ try { // Empty prefix and empty npm cache: no project dependencies or registry fallback. execFileSync('npm', ['install', '--offline', '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false', '--no-save', '--prefix', prefix, archive, '--cache', resolve(work, 'empty-npm-cache')], { cwd: work, encoding: 'utf8' }); const expected = ['LICENSE', 'README.md', 'THIRD_PARTY_NOTICES.txt', 'dist/data/catalog.v1.json', 'dist/index.js', 'licenses/semver.LICENSE', 'licenses/typescript.LICENSE.txt', 'licenses/typescript.ThirdPartyNoticeText.txt', 'package.json']; - if(publicCandidate)expected.push('SECURITY.md','CONTRIBUTING.md','FEEDBACK.md','PUBLIC_PROVENANCE.md'); + if(publicCandidate)expected.push('SECURITY.md','CONTRIBUTING.md','FEEDBACK.md','PUBLIC_PROVENANCE.md','docs/SCANNER_REPORTS.md'); expected.sort(); const packed = execFileSync('tar', ['-tzf', archive], { encoding: 'utf8' }).trim().split('\n').map((file) => file.replace(/^package\//, '')).sort(); assert.deepEqual(packed, expected); @@ -73,6 +73,21 @@ registerHooks({ resolve(specifier, context, nextResolve) { assert.deepEqual(report.integrations.map((item) => item.provider), ['openai', 'stripe']); assert.equal(report.compatibility.assessments.find((item) => item.changeId === 'openai.text-davinci-003.retirement').status, 'affected'); assert.ok(!json.includes('SYNTHETIC_PRIVATE_SENTINEL')); + const htmlPath = resolve(work, 'synthetic-report.html'); + const withHtml = run(['scan', fixture, '--json', '--html', htmlPath]); + assert.equal(withHtml, json, 'Optional HTML altered existing JSON'); + const html = await readFile(htmlPath, 'utf8'); + assert.match(html, /Content-Security-Policy/); + assert.match(html, /Affected/); + assert.ok(html.includes(pkg.version), 'Scanner version missing from archive HTML'); + assert.ok(!html.includes(fixture), 'HTML leaked absolute target'); + assert.ok(!html.includes('SYNTHETIC_PRIVATE_SENTINEL')); + assert.ok(!html.includes('process.env.KEY')); + assert.ok(!/<(?:script|img|iframe|form|link)\b/i.test(html)); + const noOverwrite = spawnSync(process.execPath, [...args, cli, 'scan', fixture, '--json', '--html', htmlPath], settings); + assert.equal(noOverwrite.status, 1); + assert.equal(noOverwrite.stdout, ''); + assert.equal(await readFile(htmlPath, 'utf8'), html); assert.equal(await readFile(resolve(fixture, 'index.ts'), 'utf8'), source); assert.equal(await readFile(resolve(fixture, 'package.json'), 'utf8'), manifestText); const changes = JSON.parse(run(['changes', '--coverage', '--json'])); @@ -80,6 +95,6 @@ registerHooks({ resolve(specifier, context, nextResolve) { assert.equal(changes.catalogCoverage.liveMonitoring, false); const invalid = spawnSync(process.execPath, [...args, cli, 'scan', '../outside', '--json'], settings); assert.equal(invalid.status, 1); - console.log(JSON.stringify({ archive, sha256: digest, installation: 'passed: empty prefix/cache, offline, scripts disabled', runtime: 'self-contained; parent dependencies blocked', network: 'common Node transports denied; help/terminal/JSON/catalog passed', executable: 'passed', targetUnchanged: true, ignoredEnv: true, traversalRejected: true, files: expected, node: process.version }, null, 2)); + console.log(JSON.stringify({ archive, sha256: digest, installation: 'passed: empty prefix/cache, offline, scripts disabled', runtime: 'self-contained; parent dependencies blocked', network: 'common Node transports denied; help/terminal/JSON/HTML/catalog passed', html: 'privacy/CSP/JSON compatibility/no-overwrite passed', executable: 'passed', targetUnchanged: true, ignoredEnv: true, traversalRejected: true, files: expected, node: process.version }, null, 2)); } finally { await rm(work, { recursive: true, force: true }); } assert.equal(root, process.cwd()); diff --git a/src/artifacts/filesystem.ts b/src/artifacts/filesystem.ts new file mode 100644 index 0000000..2d5124e --- /dev/null +++ b/src/artifacts/filesystem.ts @@ -0,0 +1,52 @@ +import { constants } from 'node:fs'; +import { lstat, open } from 'node:fs/promises'; +import { dirname, parse, resolve, sep, extname } from 'node:path'; + +export const maxHtmlBytes = 32 * 1024 * 1024; + +async function parents(target: string): Promise { + let current = parse(target).root; + const result: string[] = []; + for (const part of dirname(target).slice(current.length).split(sep).filter(Boolean)) { + current = resolve(current, part); + const stat = await lstat(current); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('Unsafe report parent.'); + result.push(`${stat.dev}:${stat.ino}`); + } + return result.join('/'); +} + +/** No-clobber, explicit output only. Portable checks do not eliminate ancestor races. */ +export async function writeNewArtifact(input: string, html: string): Promise { + try { + if (!input || input.includes('\0') || input.split(/[\\/]/u).includes('..') || extname(input).toLowerCase() !== '.html') { + throw new Error('Invalid report path.'); + } + const data = Buffer.from(html, 'utf8'); + if (data.length > maxHtmlBytes) throw new Error('HTML exceeds the 32 MiB output limit.'); + const target = resolve(input); + if (dirname(target) === parse(target).root) throw new Error('Filesystem-root output is not allowed.'); + const identity = await parents(target); + // Exclusive creation refuses ALL existing destinations, including symlinks. + const file = await open(target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600); + try { + const opened = await file.stat(); + const verify = async () => { + const current = await lstat(target); + if (!opened.isFile() || current.isSymbolicLink() || !current.isFile() + || current.dev !== opened.dev || current.ino !== opened.ino || current.nlink !== 1 + || (await file.stat()).nlink !== 1 || await parents(target) !== identity) { + throw new Error('Report destination changed.'); + } + }; + await verify(); + await file.writeFile(data); + await file.sync(); + await verify(); + if ((await file.stat()).size !== data.length) throw new Error('Incomplete report write.'); + } finally { await file.close(); } + } catch { + // Do not echo paths, OS errors, or attempt racy unlink cleanup of a replaced path. + throw new Error('HTML report not saved: destination must be a new .html file in an existing stable non-symlink directory; verify permissions and the 32 MiB limit. A failed/interrupted write may leave an incomplete file; inspect it privately and choose a new destination.'); + } +} diff --git a/src/cli/run.ts b/src/cli/run.ts index 408ca86..dc4c49c 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -5,6 +5,10 @@ import { listChanges } from '../changes/index.ts'; import { formatChangesJson, formatChangesTerminal } from '../reports/changes.ts'; import type { ChangeCatalogRepository } from '../types/changes.ts'; import type { ModuleLimits } from '../types/analysis.ts'; +import { captureReportContext } from '../reports/context.ts'; +import { buildReportView } from '../reports/view.ts'; +import { formatHtml } from '../reports/html.ts'; +import { writeNewArtifact } from '../artifacts/filesystem.ts'; export const help = `PatchWRK Scanner @@ -19,6 +23,7 @@ Commands: Options: --help, -h Show this help --json Print a JSON report + --html PATH Save a new self-contained local HTML report (never overwrite) --provider NAME Filter changes by provider --max-files N Maximum files read (default: 2000) --max-file-bytes N Maximum bytes per file (default: 1048576) @@ -78,6 +83,7 @@ export async function runCli(args: string[], output: CliOutput, services: { cata let directory: string | undefined; let json = false; let positionalOnly = false; + let htmlPath: string | undefined; const limits: Partial = {}; const analysisLimits: Partial = {}; const moduleOptions: Record = { @@ -93,6 +99,12 @@ export async function runCli(args: string[], output: CliOutput, services: { cata const arg = args[index]!; if (!positionalOnly && arg === '--') positionalOnly = true; else if (!positionalOnly && arg === '--json') json = true; + else if (!positionalOnly && arg === '--html') { + if (htmlPath !== undefined) throw new Error('Duplicate --html option.'); + const value = args[++index]; + if (!value || value.startsWith('-')) throw new Error('Expected a new .html path after --html.'); + htmlPath = value; + } else if (!positionalOnly && arg === '--api-version') { const value = args[++index]; if (!value || value.startsWith('-')) throw new Error('Expected provider=version after --api-version.'); @@ -116,7 +128,11 @@ export async function runCli(args: string[], output: CliOutput, services: { cata else if (directory !== undefined) throw new Error('Only one directory may be scanned.'); else directory = arg; } - const report = await scanWithCompatibility(directory ?? process.cwd(), { scan: { limits, analysisLimits }, apiVersionInputs, ...services }); + const options = { scan: { limits, analysisLimits }, apiVersionInputs, ...services }; + const target = directory ?? process.cwd(); + const context = htmlPath === undefined ? undefined : await captureReportContext(target, options); + const report = context?.scan ?? await scanWithCompatibility(target, options); + if (context && htmlPath !== undefined) await writeNewArtifact(htmlPath, formatHtml(buildReportView(report, context.metadata))); output.stdout(json ? formatJson(report) : formatTerminal(report)); return 0; } catch (error) { diff --git a/src/reports/context.ts b/src/reports/context.ts new file mode 100644 index 0000000..561caa4 --- /dev/null +++ b/src/reports/context.ts @@ -0,0 +1,37 @@ +import { createHash } from 'node:crypto'; +import { scanWithCompatibility } from '../compatibility/index.ts'; +import type { CompatibilityScanOptions } from '../compatibility/index.ts'; +import { LocalChangeCatalog, validateCatalog } from '../changes/catalog.ts'; +import { providers } from '../providers/index.ts'; +import type { ReportMetadata } from '../types/reporting.ts'; + +declare const PATCHWRK_VERSION: string; +declare const PATCHWRK_BUILD: string; + +/** Object keys are sorted recursively; array order is preserved after catalog validation. */ +export function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (value !== null && typeof value === 'object') { + const object = value as Record; + return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(',')}}`; + } + return JSON.stringify(value); +} + +export async function captureReportContext(directory: string, options: CompatibilityScanOptions) { + const startedAt = new Date().toISOString(); + const catalog = validateCatalog(await (options.catalog ?? new LocalChangeCatalog()).load()); + // Metadata and assessments use exactly the same snapshot, even if disk changes. + const scan = await scanWithCompatibility(directory, { ...options, catalog: { load: async () => catalog } }); + const finishedAt = new Date().toISOString(); + const metadata: ReportMetadata = { + scannerVersion: typeof PATCHWRK_VERSION === 'undefined' ? null : PATCHWRK_VERSION, + buildFingerprint: typeof PATCHWRK_BUILD === 'undefined' ? null : PATCHWRK_BUILD, + catalogVersion: catalog.catalogVersion, + catalogFingerprint: createHash('sha256').update(canonicalJson(catalog)).digest('hex'), + catalogRecordCount: catalog.changes.length, + providers: providers.map((provider) => ({ id: provider.id, name: provider.name, records: catalog.changes.filter((change) => change.provider === provider.id).length })), + startedAt, finishedAt, generatedAt: new Date().toISOString(), + }; + return { scan, metadata }; +} diff --git a/src/reports/html.ts b/src/reports/html.ts new file mode 100644 index 0000000..be6b6ce --- /dev/null +++ b/src/reports/html.ts @@ -0,0 +1,57 @@ +import { createHash } from 'node:crypto'; +import type { ReportView, ReportBlock } from '../types/reporting.ts'; +import { maxHtmlBytes } from '../artifacts/filesystem.ts'; +import { safeHttpsUrl } from '../changes/sources.ts'; + +export function escapeHtml(value: string): string { + return value.replace(/[&<>"']/gu, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!); +} + +const css = ` +:root{color-scheme:light;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#191a1c;background:#f6f5f1;font-size:16px;line-height:1.6} +*{box-sizing:border-box}body{margin:0}main{max-width:1160px;margin:auto;padding:40px 32px 64px}a{color:inherit;text-underline-offset:4px;overflow-wrap:anywhere}a:focus-visible,summary:focus-visible{outline:3px solid #151515;outline-offset:5px}.skip{position:absolute;left:16px;top:-80px;background:white;padding:12px;z-index:1}.skip:focus{top:12px}.topline,.header-meta,.finding-meta{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px}.brand{font-size:18px;font-weight:750;letter-spacing:-.03em}.tag,.generation,.badge{display:inline-block;border-radius:6px;padding:4px 10px;font-size:12px;font-weight:650}.tag{border:1px solid #d4d4cf;background:#fafaf8;color:#4e5053}.generation{background:#edf6ef;color:#245537;border:1px solid #c8dfce}.header-meta{justify-content:flex-start;margin-top:22px;font-size:12px;color:#606267}h1{font-size:clamp(30px,4.2vw,44px);line-height:1.2;letter-spacing:-.045em;margin:18px 0 8px}h2{font-size:22px;line-height:1.35;letter-spacing:-.025em;margin:0}h3{font-size:18px;line-height:1.4;letter-spacing:-.015em;margin:0 0 10px}h4{font-size:13px;margin:0 0 6px}p{margin:10px 0}.lede,.muted{color:#55585d}.lede{margin-top:0}.small{font-size:13px}.section-heading{display:flex;align-items:baseline;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:16px}.section-heading p{margin:0}.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.stat{padding:20px 22px;background:white;border:1px solid #deded7;border-radius:12px}.number{font-size:32px;line-height:1.15;letter-spacing:-.04em;font-weight:680;display:block;margin-bottom:8px}.stat-label{font-size:13px;color:#55585d;display:block}.metric-note{font-size:12px;color:#5e6064;margin:12px 0 18px}.block,.scan-status,.finding,.technical{background:white;border:1px solid #deded7;border-radius:12px;padding:22px;margin-bottom:14px;min-width:0}.scan-status{padding:18px 22px}.status-line{display:flex;justify-content:space-between;align-items:baseline;flex-wrap:wrap;gap:8px}.coverage-warning{font-weight:650;color:#805000}.coverage-line{display:flex;flex-wrap:wrap;gap:8px 20px;font-size:12px;color:#55585d;margin:10px 0}.invocation{font-size:12px;margin:10px 0}.invocation code{display:block;background:#f6f5f1;border-radius:6px;padding:10px 12px;margin-top:6px;color:#323438}section{margin-top:32px}.finding{padding:24px;border-left:3px solid #c8c9cb}.finding.tone-affected{border-left-color:#b43b3b}.finding.tone-potential{border-left-color:#ac761e}.finding-meta{margin-bottom:14px}.provider{font-size:12px;font-weight:650;color:#55585d;letter-spacing:.02em}.badge{background:#f0f0ee;color:#44484e;border:1px solid #d9dad8}.badge.tone-affected{background:#fff0f0;color:#9d2929;border-color:#efc8c8}.badge.tone-potential{background:#fff6e6;color:#805000;border-color:#ead4a9}.badge.tone-unknown,.badge.tone-neutral{background:#f1f2f3;color:#4a5058;border-color:#d7dade}.finding-explanation{max-width:85ch;font-size:14px}.references{display:flex;flex-wrap:wrap;gap:6px 14px;list-style:none;padding:0;margin:12px 0;font-size:12px}.references li{margin:0}.references code{background:#f6f5f1;padding:3px 7px;border-radius:4px}.uncertainty{border-left:2px solid #d7d9dc;padding:0 0 0 14px;margin:18px 0;font-size:13px;color:#4a5058}.uncertainty ul,.next-actions ul{margin:0}.next-actions{background:#f7f7f4;border-radius:8px;padding:14px 16px;font-size:13px;margin:16px 0}.documentation{font-size:13px}details{border-top:1px solid #e5e5df;padding-top:14px;margin-top:18px}summary{cursor:pointer;font-size:13px;font-weight:650;min-height:28px;overflow-wrap:anywhere}summary::marker{color:#606267}details[open]>summary{margin-bottom:16px}.technical{margin-top:0}.technical>summary{font-size:14px}.technical .block{border:0;border-radius:0;padding:8px 0;margin:0}.rows{margin:0}.pair{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,2fr);gap:20px;padding:9px 0;border-top:1px solid #eeeeea}.pair dt{font-size:12px;color:#55585d}.pair dd{margin:0;overflow-wrap:anywhere;font-size:13px}.integrations{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.integrations .block{margin:0}ul{padding-left:20px}li{margin:7px 0;overflow-wrap:anywhere}code{font-family:ui-monospace,Menlo,monospace;font-size:.92em;overflow-wrap:anywhere}.critical-notices{font-size:12px;color:#55585d;margin:10px 0 0}.technical-intro,footer{font-size:12px;color:#55585d}footer{margin-top:32px;border-top:1px solid #d5d5cf;padding-top:18px}nav{display:flex;gap:8px 22px;flex-wrap:wrap;margin-top:26px;padding:15px 0;border-top:1px solid #deded7;border-bottom:1px solid #deded7;font-size:13px}nav a{text-decoration:none;padding:3px 0}nav a:hover{text-decoration:underline}h3,.badge{overflow-wrap:anywhere} +@media(max-width:900px){.integrations{grid-template-columns:1fr}.stats{gap:10px}.stat{padding:16px}.pair{grid-template-columns:1fr;gap:3px}} +@media(max-width:700px){main{padding:24px 16px 40px}.stats{grid-template-columns:repeat(2,minmax(0,1fr))}.block,.finding,.scan-status,.technical{padding:18px}.number{font-size:28px}.topline{align-items:flex-start}.references{display:block}.references li{margin:7px 0}.section-heading{align-items:flex-start}nav{gap:10px 18px}.finding-meta{align-items:flex-start}} +@media print{body{background:white}main{padding:0;max-width:none}nav,.skip{display:none}.block,.finding,.stat,.technical{border-color:#999;break-inside:avoid}a{text-decoration:underline}.stats{grid-template-columns:repeat(4,minmax(0,1fr))}.integrations{grid-template-columns:1fr}h2{break-after:avoid}details>*{display:block!important}details::details-content{content-visibility:visible;height:auto}} +`; + +export function formatHtml(view: ReportView): string { + const text = escapeHtml; + const rows = (values: { label: string; value: string }[]) => `
${values.map((value) => `
${text(value.label)}
${text(value.value)}
`).join('')}
`; + const notes = (values: string[]) => values.length ? `
    ${values.map((note) => `
  • ${text(note)}
  • `).join('')}
` : ''; + const source = (value: ReportBlock) => value.sourceUrl && safeHttpsUrl(value.sourceUrl) ? `

Official provider documentation (external website if followed)

` : ''; + const tone = (status: string | undefined) => status === 'Affected' ? 'tone-affected' : status === 'Potential risk' ? 'tone-potential' : status === 'Unknown' || status === 'Unknown / partial coverage' ? 'tone-unknown' : 'tone-neutral'; + const badge = (value: ReportBlock) => value.status ? `${text(value.status)}` : ''; + const block = (value: ReportBlock) => `

${text(value.title)}

${badge(value)}${rows(value.rows)}${notes(value.notes)}${source(value)}
`; + const finding = (value: ReportBlock) => { + const summary = value.finding; + if (!summary) return block(value); + return `
${text(summary.provider)}${badge(value)}

${text(summary.title)}

${text(summary.explanation)}

    ${summary.references.map((reference) => `
  • ${text(reference)}
  • `).join('')}
${summary.moreReferences ? `

${summary.moreReferences} additional source references in full evidence below.

` : ''}

What remains unknown

${notes(summary.uncertainty)}
${source(value)}
Full assessment evidence and provenance

${text(value.title)}

${rows(value.rows)}${notes(value.notes)}
`; + }; + const section = (kind: ReportView['sections'][number]['kind']) => view.sections.find((item) => item.kind === kind); + const blocks = (kind: ReportView['sections'][number]['kind']) => (section(kind)?.blocks ?? []).map(block).join(''); + const findings = [...(section('findings')?.blocks ?? [])]; + const priority = ['Affected', 'Potential risk', 'Unknown', 'Unknown / partial coverage', 'Not affected by this change']; + findings.sort((a, b) => (priority.indexOf(a.status ?? '') < 0 ? 5 : priority.indexOf(a.status!)) - (priority.indexOf(b.status ?? '') < 0 ? 5 : priority.indexOf(b.status!))); + const metadata = view.metadata; + const hash = createHash('sha256').update(css).digest('base64'); + const html = ` +PatchWRK Scanner — Local report +
PatchWRK Scanner
Experimental beta

API Compatibility Report

Local, read-only analysis of supported third-party API integrations.

Report generatedScanner ${text(metadata.scannerVersion ?? 'version unknown (source execution)')}Manual scan · no continuous monitoring
+

Overview

A snapshot of supported evidence

${view.stats.map((stat) => `
${text(String(stat.value))}${text(stat.label)}
`).join('')}

Attention = Affected + Potential risk assessments. Unknown assessments also require review. Counts describe recorded findings, not application health.

+
Scan status${view.complete ? 'Eligible supported scope analyzed' : 'Report generated · partial coverage — investigate unknowns'}

Manual scan · Offline execution · Read-only static analysis

Selected local project; absolute path omitted. Supported scope only. Successful report generation does not establish application compatibility.

${view.coverage.map((item) => `${text(item.label)}: ${text(item.value)}`).join('')}
Invocation — redacted equivalentpatchwrk scan <selected-project> --html <new-report.html>

Raw arguments, target/output paths and API-version values are omitted. Full scope and resource budgets are available in technical details.

${view.notices.length ? `
${notes(view.notices)}
` : ''}
+

Findings

Historical records · evidence-backed assessments

${findings.map(finding).join('')}
+

Recommended actions

Manual investigation · no automatic changes

${blocks('actions')}
+

Detected integrations

SDK declarations and source references

${blocks('integrations')}
+

Technical details

Expand for complete recorded evidence

Experimental beta. Discovery supports five providers; the bundled production catalog has four historical Stripe/OpenAI records. Unsupported patterns may be missed. Runtime and production behavior are unverified. No monitoring or automatic repair.

+
Scanner, catalog and scan identity${rows([ + { label: 'Scan started (UTC)', value: metadata.startedAt }, { label: 'Scan finished (UTC)', value: metadata.finishedAt }, { label: 'Report generated (UTC)', value: metadata.generatedAt }, + { label: 'Scanner version', value: metadata.scannerVersion ?? 'Unknown (source execution)' }, + { label: 'Build input fingerprint (SHA-256)', value: metadata.buildFingerprint ?? 'Unknown (source execution)' }, + { label: 'Catalog revision / records', value: `${metadata.catalogVersion} / ${metadata.catalogRecordCount}` }, + { label: 'Catalog content fingerprint (SHA-256)', value: metadata.catalogFingerprint }, + ])}
Scan scope, coverage and resource ceilings${blocks('scope')}
Assessment status definitions and counts${blocks('attention')}
Provider coverage, provenance and all limitations${blocks('limitations')}
+
Generated locally by PatchWRK Scanner. No source execution, monitoring, automatic repair, telemetry or upload. This report is a privacy-minimized visual projection, not the full JSON report. No overall health score.
`; + if (Buffer.byteLength(html, 'utf8') > maxHtmlBytes) throw new Error('HTML exceeds the 32 MiB output limit.'); + return html; +} diff --git a/src/reports/view.ts b/src/reports/view.ts new file mode 100644 index 0000000..3177421 --- /dev/null +++ b/src/reports/view.ts @@ -0,0 +1,177 @@ +import { createHash } from 'node:crypto'; +import { validRange } from 'semver'; +import type { CompatibilityScanReport } from '../types/compatibility.ts'; +import type { ReportView, ReportMetadata, ReportBlock } from '../types/reporting.ts'; +import { isOfficialUrl } from '../changes/sources.ts'; + +export const statusLabels = { + affected: 'Affected', 'potentially-affected': 'Potential risk', + unknown: 'Unknown', 'not-affected-by-this-change': 'Not affected by this change', +} as const; + +const privateText = /(?:secret|credential|password|bearer|token|canary|sentinel|sk[-_]|api[-_]?key|private[-_]?key)/iu; +const withheld = '[withheld for privacy]'; + +/** References remain inert text. Suspicious paths are omitted, never linked. */ +function sourcePath(value: string): string { + return value.length <= 240 && !privateText.test(value) && !value.split('/').some((part) => part === '..' || part === '.' || part.length > 64) + && /^(?:[a-zA-Z0-9_@-][a-zA-Z0-9_.@-]*\/)*[a-zA-Z0-9_@-][a-zA-Z0-9_.@-]*$/u.test(value) + ? value : withheld; +} + +/** Warnings contain source labels; never retain their quoted payloads or absolute paths. */ +function narrative(value: string): string { + const clean = value.replace(/"(?:[^"\\]|\\.)*"/gu, withheld); + const unsafeControl = [...clean].some((character) => { + const code = character.charCodeAt(0); + return (code < 32 && code !== 9 && code !== 10) || code === 127 || (code >= 0x202a && code <= 0x202e) || (code >= 0x2066 && code <= 0x2069); + }); + return privateText.test(clean) || /(?:^|\s)(?:\/|[A-Za-z]:[\\/])/u.test(clean) || unsafeControl + ? 'Additional detail withheld for privacy; retain the original local terminal/JSON report for private review.' : clean; +} + +function version(value: string | null): string { + return value && value.length <= 256 && /^[0-9v^~<>=*|.+ -]+(?:[a-zA-Z0-9.-]*)$/u.test(value) && !privateText.test(value) && validRange(value, { loose: false }) + ? value : 'Unknown or withheld'; +} + +/** Bound work before projecting an internal report. This is not a saved-report importer. */ +function checkBudget(root: unknown): void { + const pending: [unknown, number][] = [[root, 0]]; + let nodes = 0; + let textBytes = 0; + while (pending.length) { + const [value, depth] = pending.pop()!; + if (++nodes > 100_000 || depth > 64) throw new Error('HTML report structure limit exceeded.'); + if (typeof value === 'string') textBytes += Buffer.byteLength(value, 'utf8'); + if (textBytes > 32 * 1024 * 1024) throw new Error('HTML report input limit exceeded.'); + if (value !== null && typeof value === 'object') { + for (const descriptor of Object.values(Object.getOwnPropertyDescriptors(value))) { + if (descriptor.get || descriptor.set) throw new Error('Unsafe report property.'); + pending.push([descriptor.value, depth + 1]); + if (pending.length > 100_000) throw new Error('HTML report structure limit exceeded.'); + } + } + } +} + +export function buildReportView(scan: CompatibilityScanReport, metadata: ReportMetadata): ReportView { + checkBudget(scan); + const complete = scan.coverage.complete && (scan.analysis?.complete ?? true) && scan.compatibility.coverage.complete; + const row = (label: string, value: unknown) => ({ label, value: String(value) }); + const scopes: ReportBlock[] = [{ + title: 'Selected local project (absolute path omitted)', + rows: [row('Mode', 'Manual · offline · read-only static analysis'), row('Invocation — redacted equivalent', 'patchwrk scan --html '), + row('Invocation omissions', 'Target/output paths, raw arguments and API-version declaration values omitted. Effective budgets are recorded below.'), + row('Files read', scan.coverage.filesScanned), row('Bytes read', scan.coverage.bytesScanned), row('Entries visited', scan.coverage.entriesVisited), row('Files skipped', scan.coverage.filesSkipped), + row('Discovery coverage', scan.coverage.complete ? 'Eligible scope analyzed' : 'Partial'), + row('Module coverage', scan.analysis?.complete ? 'Indexed scope analyzed' : 'Unknown / partial'), + row('Assessment coverage', scan.compatibility.coverage.complete ? 'Supported candidates displayed' : 'Partial'), + row('Assessments truncated', scan.compatibility.coverage.assessmentsTruncated), row('Evidence display truncated', scan.compatibility.coverage.evidenceDisplayTruncated), + ...Object.entries(scan.coverage.limits).map(([name, value]) => row(`Scan budget: ${name}`, value)), + ...Object.entries(scan.analysis?.limits ?? {}).map(([name, value]) => row(`Module budget: ${name}`, value)), + ...Object.entries(scan.compatibility.coverage.limits).map(([name, value]) => row(`Assessment budget: ${name}`, value))], + notes: ['Supported JS/JSX/TS/TSX/MJS/CJS/MTS/CTS source, package manifests and supported npm v2/v3 lock records. Literal imports/requires and directly bound clients; bounded static cross-file relationships.', + 'Hidden, generated, credential, dependency, vendor and fixture paths are excluded. Unsupported dynamic imports, wrappers and runtime injection can be missed.', + 'Sanitized invocation is descriptive, not an exact replay command. User-declared API versions are not runtime verified.'], + }]; + const integrations: ReportBlock[] = scan.integrations.map((item) => ({ + title: `${metadata.providers.find((provider) => provider.id === item.provider)?.name ?? item.provider} · ${item.packageName}`, + rows: item.declaredVersions.flatMap((declaration) => [row('Manifest', sourcePath(declaration.file)), row('Dependency section', declaration.section), + row('Declared SDK version', version(declaration.version)), row('Recorded SDK resolution', version(declaration.resolvedVersion)), row('Resolution status', declaration.resolutionStatus)]) + .concat([row('Installed runtime version', 'Unknown — not runtime verified')]) + .concat(item.locations.map((location) => row('Source evidence', `${sourcePath(location.file)}:${location.line} · ${location.type}${location.typeOnly ? ' · type only' : ''}`))), + notes: [!item.declaredVersions.length ? 'No supported SDK declaration; installed version unknown.' : 'A declaration or lockfile record does not prove the installed or deployed SDK version.'], + })); + const findings: ReportBlock[] = scan.compatibility.assessments.map((assessment) => { + const block: ReportBlock = { + title: `${assessment.provider} · ${assessment.changeId}`, + status: statusLabels[assessment.status], + rows: [row('Historical record', narrative(assessment.changeRecord.title)), row('Category', assessment.category), row('Scope', assessment.scope), + row('Evidence category', assessment.evidenceLevel), row('Existing machine status', assessment.status), + row('Finding reference', createHash('sha256').update(JSON.stringify([assessment.provider, assessment.packageName, assessment.changeId, + assessment.client?.definition.id ?? null, assessment.client?.manifestResolution.manifest ?? null])).digest('hex')), + row('SDK evaluation', `${assessment.sdkEvaluation.outcome} · ${assessment.sdkEvaluation.evidenceLevel}`), + row('Remote API evaluation', `${assessment.apiEvaluation.outcome} · ${assessment.apiEvaluation.evidenceLevel}`), + row('Configuration evaluation', `${assessment.configurationEvaluation.outcome} · ${assessment.configurationEvaluation.evidenceLevel}`), + row('Source verification', `${assessment.verification.status} · ${assessment.provenance.kind}`), row('Last reviewed', assessment.lastReviewedAt ?? 'Unknown'), + ...assessment.provenance.sources.map((source) => row('Source retrieval date (manual curation)', source.retrievedAt ?? 'Unknown')), + ...(assessment.client ? [row('Original client definition', `${sourcePath(assessment.client.definition.file)}:${assessment.client.definition.line}`), + row('Definition manifest', assessment.client.manifestResolution.manifest ? sourcePath(assessment.client.manifestResolution.manifest) : 'Unknown'), + row('Manifest resolution', `${assessment.client.manifestResolution.status} · ${assessment.client.manifestResolution.basis}`)] : []), + ...assessment.locations.map((location) => row('Source reference', `${sourcePath(location.file)}:${location.line} · ${location.type}`)), + ...assessment.configurationEvidence.map((configuration) => row('Observed source configuration', + `${sourcePath(configuration.file)}:${configuration.line} · ${configuration.scope} · ${configuration.kind} · ${configuration.status} (value withheld; not runtime verified)`)), + row('User-declared API versions', assessment.userDeclared.length ? 'Present; values withheld; not runtime verified' : 'None'), + row('Evidence display complete', assessment.facts.displayComplete)], + notes: [...assessment.inferred.rationale, ...assessment.unknowns, ...assessment.limitations, ...(assessment.client?.limitations ?? [])].map(narrative), + }; + const references = [...new Set(assessment.locations.map((location) => `${sourcePath(location.file)}:${location.line}`))]; + const dynamic = assessment.configurationEvidence.some((item) => item.status === 'dynamic' || item.status === 'unsupported'); + const unresolved = assessment.configurationEvidence.some((item) => item.status === 'unknown'); + const unknownSdk = !assessment.facts.sdkDeclarations.length || assessment.facts.sdkDeclarations.some((item) => item.resolvedVersion === null); + const explanations = { + affected: 'The recorded evidence matches this historical change. Applicability is conditional; this does not establish runtime failure.', + 'potentially-affected': 'Existing evidence is incomplete, conflicting or conditional. Investigate the supporting evidence before drawing a conclusion.', + unknown: 'The available evidence cannot determine applicability to this historical change. Unknown is not a pass.', + 'not-affected-by-this-change': 'The recorded evidence excludes this specific historical change. This is not an overall compatibility guarantee.', + }; + block.finding = { + provider: `${metadata.providers.find((provider) => provider.id === assessment.provider)?.name ?? assessment.provider} · ${assessment.packageName}`, + title: narrative(assessment.changeRecord.title), explanation: explanations[assessment.status], + references: references.slice(0, 3), moreReferences: Math.max(0, references.length - 3), + uncertainty: [...assessment.unknowns.map(narrative), + ...(dynamic ? ['Observed configuration is dynamic or unsupported; the runtime value was not evaluated.'] : []), + ...(unresolved ? ['Observed default configuration remains unresolved; source literals are not runtime verification.'] : []), + ...(!assessment.facts.displayComplete ? ['Evidence display is truncated. Review all available local evidence before drawing conclusions.'] : [])], + actions: [ + 'Review this record’s official provider documentation and historical scope.', + ...(unknownSdk ? ['Verify the actual installed and deployed SDK version independently.'] : []), + ...(dynamic ? ['Investigate dynamic configuration in the referenced source; Scanner does not evaluate it.'] : []), + ...(unresolved && !dynamic ? ['Confirm unresolved default configuration in your authorized environment.'] : []), + ...(assessment.status === 'affected' || assessment.status === 'potentially-affected' ? ['If you choose a migration, test it manually. No repair was generated or verified.'] : []), + ], + }; + if (assessment.officialSourceUrl && isOfficialUrl(assessment.provider, assessment.officialSourceUrl) + && !privateText.test(assessment.officialSourceUrl) && !new URL(assessment.officialSourceUrl).search) block.sourceUrl = assessment.officialSourceUrl; + return block; + }); + for (const integration of scan.integrations) { + if (!scan.compatibility.assessments.some((item) => item.provider === integration.provider && item.packageName === integration.packageName)) { + findings.push({ title: `${integration.provider} · ${integration.packageName}`, status: complete ? 'No known finding within supported coverage' : 'Unknown / partial coverage', rows: [], + notes: ['No matched historical assessment was recorded. This does not establish compatibility; discovery and catalog scope are limited.'] }); + } + } + const attention: ReportBlock[] = Object.entries(statusLabels).map(([status, label]) => ({ title: label, + rows: [row('Assessments', scan.compatibility.assessments.filter((assessment) => assessment.status === status).length)], + notes: [status === 'affected' ? 'Conditional applicability to a specific historical record, not confirmed runtime failure. Category may be nonbreaking.' + : status === 'potentially-affected' ? 'Existing evidence is partial, conflicting or conditional; investigate the reported uncertainty.' + : status === 'unknown' ? 'Missing evidence is not a pass. Confirm actual SDK and runtime configuration independently.' : 'Exclusion is scoped to one change, not the application or integration as a whole.'], + })); + return { + schemaVersion: 1, metadata: structuredClone(metadata), complete, + notices: [...scan.warnings, ...(scan.analysis?.warnings ?? []), ...scan.compatibility.warnings].map(narrative), + coverage: scopes[0]!.rows.filter((item) => ['Discovery coverage', 'Module coverage', 'Assessment coverage', 'Assessments truncated', 'Evidence display truncated'].includes(item.label)), + stats: [{ label: 'Files scanned', value: scan.coverage.filesScanned }, { label: 'Supported integrations', value: scan.integrations.length }, + { label: 'Assessments requiring attention', value: scan.compatibility.assessments.filter((item) => item.status === 'affected' || item.status === 'potentially-affected').length }, { label: 'Unknown assessments', value: scan.compatibility.assessments.filter((item) => item.status === 'unknown').length }], + sections: [ + { kind: 'scope', title: 'What was scanned', blocks: scopes }, + { kind: 'integrations', title: 'Detected integrations', blocks: integrations.length ? integrations : [{ title: 'No supported integration detected', rows: [], notes: ['Empty findings do not establish absence of integrations or compatibility. Unsupported patterns can be missed.'] }] }, + { kind: 'findings', title: 'Findings and supporting evidence', blocks: findings.length ? findings : [{ title: 'No historical assessment', rows: [], notes: ['No matched assessment does not establish compatibility or repository health.'] }] }, + { kind: 'attention', title: 'What needs attention', blocks: attention }, + { kind: 'actions', title: 'Recommended next steps', blocks: [{ title: 'Developer review', rows: [], notes: [ + 'Review the relevant source locations and recorded official provider guidance. Check historical dates; a suggested replacement may itself have retired.', + 'Investigate unknown SDK/API versions and confirm dynamic model, client and endpoint configuration independently.', + 'If you choose a supported migration, test it manually in your own authorized environment. Scanner generated no patch and verified no repair.', + ] }] }, + { kind: 'limitations', title: 'Coverage, provenance and limitations', blocks: [{ title: 'Supported provider coverage', rows: metadata.providers.map((provider) => row(provider.name, `Supported SDK discovery · ${provider.records} historical catalog records · no monitoring or repair`)), notes: [ + 'Experimental beta. Current bundled production catalog has four historical Stripe/OpenAI records; other supported providers have no curated records.', + 'Static evidence does not establish runtime reachability, production breakage, compatibility or application health. No automatic repair or Cloud monitoring.', + 'Report paths are root-relative and may still identify private code. Unsafe path labels and configuration values are withheld. No source excerpts, absolute target paths or raw arguments are stored in HTML. Review privately before sharing.', + 'This visual projection intentionally omits sensitive values and quoted warning details; the original local terminal/JSON preserves existing evidence. No history, comparison, telemetry or upload occurs.', + 'Fingerprints identify content, not authenticity. Review dates describe manual curation, not continuous freshness. Source-mode version/build identity may be unavailable.', + ...scan.warnings, ...scan.coverage.limitations, ...(scan.analysis?.warnings ?? []), ...(scan.analysis?.limitations ?? []), ...scan.compatibility.warnings, ...scan.compatibility.limitations, + ].map(narrative) }] }, + ], + }; +} diff --git a/src/types/reporting.ts b/src/types/reporting.ts new file mode 100644 index 0000000..c938cea --- /dev/null +++ b/src/types/reporting.ts @@ -0,0 +1,39 @@ +/** Independent presentation contract; does not change scanner schema 2. */ +export interface ReportMetadata { + scannerVersion: string | null; + buildFingerprint: string | null; + catalogVersion: string; + catalogFingerprint: string; + catalogRecordCount: number; + providers: { id: string; name: string; records: number }[]; + startedAt: string; + finishedAt: string; + generatedAt: string; +} + +export interface ReportView { + schemaVersion: 1; + metadata: ReportMetadata; + complete: boolean; + stats: { label: string; value: number }[]; + notices: string[]; + coverage: { label: string; value: string }[]; + sections: { kind: 'scope' | 'integrations' | 'findings' | 'attention' | 'actions' | 'limitations'; title: string; blocks: ReportBlock[] }[]; +} + +export interface ReportBlock { + title: string; + rows: { label: string; value: string }[]; + notes: string[]; + status?: string; + sourceUrl?: string; + finding?: { + provider: string; + title: string; + explanation: string; + references: string[]; + moreReferences: number; + uncertainty: string[]; + actions: string[]; + }; +} diff --git a/tests/artifact-filesystem.test.ts b/tests/artifact-filesystem.test.ts new file mode 100644 index 0000000..424530a --- /dev/null +++ b/tests/artifact-filesystem.test.ts @@ -0,0 +1,92 @@ +import { lstat, open, readFile, writeFile, mkdtemp, mkdir, symlink, link, rename, rm } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { beforeEach, afterEach, expect, it, vi } from 'vitest'; +import { writeNewArtifact, maxHtmlBytes } from '../src/artifacts/filesystem.ts'; + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, open: vi.fn(actual.open) }; +}); +const actual = await vi.importActual('node:fs/promises'); +let sandbox: string; +beforeEach(async () => { + vi.mocked(open).mockReset().mockImplementation(actual.open); + sandbox = await mkdtemp(resolve('.scanner-test-artifact-')); +}); +afterEach(async () => { vi.restoreAllMocks(); await rm(sandbox, { recursive: true, force: true }); }); + +it('creates only a new explicitly selected HTML file with private permissions', async () => { + const file = resolve(sandbox, 'report.html'); + await writeNewArtifact(file, 'actual report'); + expect(await readFile(file, 'utf8')).toBe('actual report'); + expect((await lstat(file)).mode & 0o777).toBe(0o600); + await expect(writeNewArtifact(file, 'replacement')).rejects.toThrow('not saved'); + expect(await readFile(file, 'utf8')).toBe('actual report'); +}); + +it.each(['../report.html', 'bad\0.html', '/report.ts', 'package.json', 'source.ts', ''])('refuses unsafe or non-HTML destination %j', async (path) => { + await expect(writeNewArtifact(path, 'data')).rejects.toThrow('not saved'); +}); + +it('rejects existing links, directory destinations, missing parents and linked ancestors', async () => { + const original = resolve(sandbox, 'original.html'); + await writeFile(original, 'original'); + await symlink(original, resolve(sandbox, 'symlink.html')); + await link(original, resolve(sandbox, 'hardlink.html')); + await mkdir(resolve(sandbox, 'directory.html')); + await symlink(sandbox, resolve(sandbox, 'parent')); + for (const name of ['symlink.html', 'hardlink.html', 'directory.html', 'missing/report.html', 'parent/report.html']) { + await expect(writeNewArtifact(resolve(sandbox, name), 'replacement')).rejects.toThrow('not saved'); + } + expect(await readFile(original, 'utf8')).toBe('original'); +}); + +it('allows only one concurrent creator and bounds output size', async () => { + const file = resolve(sandbox, 'race.html'); + const results = await Promise.allSettled([writeNewArtifact(file, 'first'), writeNewArtifact(file, 'second')]); + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1); + await expect(writeNewArtifact(resolve(sandbox, 'large.html'), 'x'.repeat(maxHtmlBytes + 1))).rejects.toThrow('32 MiB'); +}); + +it('detects ancestor replacement before writing but documents creation race residual', async () => { + const parent = resolve(sandbox, 'parent'); + const outside = resolve(sandbox, 'other'); + await mkdir(parent); await mkdir(outside); + vi.mocked(open).mockImplementationOnce(async (path, flags, mode) => { + await rename(parent, resolve(sandbox, 'old-parent')); + await symlink(outside, parent); + return actual.open(path, flags, mode); + }); + await expect(writeNewArtifact(resolve(parent, 'report.html'), 'must not write')).rejects.toThrow('not saved'); + // Portable Node cannot stop creation via a swapped ancestor. No content was written. + expect(await readFile(resolve(outside, 'report.html'), 'utf8')).toBe(''); +}); + +it('does not delete a replaced path after a failed write', async () => { + const file = resolve(sandbox, 'replaced.html'); + vi.mocked(open).mockImplementationOnce(async (path, flags, mode) => { + const handle = await actual.open(path, flags, mode); + vi.spyOn(handle, 'writeFile').mockImplementationOnce(async () => { + await rename(file, resolve(sandbox, 'old.html')); + await writeFile(file, 'unrelated replacement'); + throw new Error('interrupted'); + }); + return handle; + }); + await expect(writeNewArtifact(file, 'new')).rejects.toThrow('incomplete file'); + expect(await readFile(file, 'utf8')).toBe('unrelated replacement'); +}); + +it('closes interrupted writes and never claims success or leaks OS error paths', async () => { + let closed = false; + vi.mocked(open).mockImplementationOnce(async (path, flags, mode) => { + const handle = await actual.open(path, flags, mode); + const close = handle.close.bind(handle); + vi.spyOn(handle, 'close').mockImplementation(async () => { closed = true; await close(); }); + vi.spyOn(handle, 'writeFile').mockRejectedValueOnce(new Error('/Users/PRIVATE_SECRET disk full')); + return handle; + }); + await expect(writeNewArtifact(resolve(sandbox, 'partial.html'), 'new')).rejects.toThrow('not saved'); + expect(closed).toBe(true); + expect(await readFile(resolve(sandbox, 'partial.html'), 'utf8')).toBe(''); +}); diff --git a/tests/html-reports.test.ts b/tests/html-reports.test.ts new file mode 100644 index 0000000..ba5aed8 --- /dev/null +++ b/tests/html-reports.test.ts @@ -0,0 +1,308 @@ +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { createHash } from 'node:crypto'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { runCli } from '../src/cli/run.ts'; +import { captureReportContext, canonicalJson } from '../src/reports/context.ts'; +import { buildReportView } from '../src/reports/view.ts'; +import { escapeHtml, formatHtml } from '../src/reports/html.ts'; +import { LocalChangeCatalog } from '../src/changes/catalog.ts'; + +let sandbox: string; +let target: string; +beforeEach(async () => { + sandbox = await mkdtemp(resolve('.scanner-test-html-')); + target = resolve(sandbox, 'project'); + await mkdir(target); +}); +afterEach(async () => { vi.restoreAllMocks(); await rm(sandbox, { recursive: true, force: true }); }); + +async function cli(args: string[]) { + const stdout: string[] = []; const stderr: string[] = []; + const code = await runCli(args, { stdout: (text) => stdout.push(text), stderr: (text) => stderr.push(text) }); + return { code, stdout: stdout.join('\n'), stderr: stderr.join('\n') }; +} +async function fixture(model: string) { + await writeFile(resolve(target, 'package.json'), '{"dependencies":{"openai":"4.0.0"}}'); + await writeFile(resolve(target, 'index.ts'), `import OpenAI from 'openai'; const client = new OpenAI(); client.completions.create({model:${model}}); throw new Error('DO_NOT_EXECUTE');`); +} + +it('escapes every HTML delimiter including quotes without creating markup', () => { + expect(escapeHtml(``)).toBe('<script a="x">'&</script>'); +}); + +it('uses one catalog load and fingerprints validated content deterministically', async () => { + const catalog = await new LocalChangeCatalog().load(); + const load = vi.fn(async () => catalog); + const context = await captureReportContext(target, { catalog: { load } }); + expect(load).toHaveBeenCalledTimes(1); + expect(context.metadata.catalogVersion).toBe('3'); + expect(context.metadata.catalogRecordCount).toBe(4); + expect(canonicalJson({ z: 2, a: [1, { b: 2, a: 1 }] })).toBe('{"a":[1,{"a":1,"b":2}],"z":2}'); + expect(context.metadata.catalogFingerprint).toMatch(/^[a-f0-9]{64}$/u); + expect(context.metadata.startedAt <= context.metadata.finishedAt).toBe(true); + expect(context.metadata.scannerVersion).toBeNull(); +}); + +it('preserves default terminal and JSON bytes with optional HTML and never persists by default', async () => { + await fixture("'text-davinci-003'"); + const before = await readFile(resolve(target, 'index.ts')); + const terminal = await cli(['scan', target]); + expect(await readdir(sandbox)).toEqual(['project']); + const htmlPath = resolve(sandbox, 'report.html'); + const visual = await cli(['scan', target, '--html', htmlPath]); + expect(visual).toEqual(terminal); + const json = await cli(['scan', target, '--json']); + const withHtml = await cli(['scan', target, '--json', '--html', resolve(sandbox, 'report-json.html')]); + expect(withHtml).toEqual(json); + expect(JSON.parse(json.stdout).schemaVersion).toBe(2); + expect(await readFile(resolve(target, 'index.ts'))).toEqual(before); + const html = await readFile(htmlPath, 'utf8'); + expect(html).toContain('Affected'); + expect(html).toContain('index.ts:1'); + expect(html).toContain('Not affected by this change'); + expect(html).toContain('Unknown or withheld'); + expect(html).not.toContain(target); + expect(html).not.toContain('DO_NOT_EXECUTE'); + expect(html).not.toContain('new OpenAI'); + expect(html).toContain('redacted equivalent'); +}); + +it('reports dynamic evidence as unknown, not a repair, and keeps projected state independent', async () => { + await fixture('process.env.SYNTHETIC_SECRET_CANARY'); + const context = await captureReportContext(target, {}); + const original = structuredClone(context.scan); + const view = buildReportView(context.scan, context.metadata); + expect(context.scan).toEqual(original); + const html = formatHtml(view); + expect(context.scan.compatibility.assessments.map((item) => item.status)).toEqual(['unknown', 'unknown']); + expect(html).toContain('Unknown'); + expect(html).toContain('dynamic'); + expect(html).not.toContain('SYNTHETIC_SECRET_CANARY'); + expect(html).not.toContain('process.env'); +}); + +it('handles empty, no-catalog provider and partial scans without inventing health', async () => { + let context = await captureReportContext(target, {}); + expect(formatHtml(buildReportView(context.scan, context.metadata))).toContain('No supported integration detected'); + await writeFile(resolve(target, 'package.json'), '{"dependencies":{"twilio":"5.0.0"}}'); + context = await captureReportContext(target, {}); + expect(formatHtml(buildReportView(context.scan, context.metadata))).toContain('No known finding within supported coverage'); + context.scan.coverage.complete = false; + const partial = formatHtml(buildReportView(context.scan, context.metadata)); + expect(partial).toContain('Unknown / partial coverage'); + expect(partial).toContain('partial coverage — investigate unknowns'); + expect(partial).not.toContain('No known finding within supported coverage'); +}); + +it('withholds malicious path labels, arbitrary literals, declarations and quoted warnings', async () => { + await fixture("'secret-canary-model'"); + const context = await captureReportContext(target, {}); + context.scan.integrations[0]!.locations[0]!.file = `">.ts`; + context.scan.integrations[0]!.declaredVersions[0]!.version = 'SECRET_CANARY'; + context.scan.warnings.push('Unreadable "SYNTHETIC_SECRET_CANARY".', 'Directory /Users/private/SECRET_CANARY unreadable.'); + context.scan.compatibility.assessments[0]!.unknowns.push(``); + const html = formatHtml(buildReportView(context.scan, context.metadata)); + expect(html).not.toContain('