Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ node_modules/
dist/
.cache/
artifacts/
# Scanner artifact writer is source, not generated report output.
!/src/artifacts/
.scanner-test-*/
*.log
*.tgz
Expand Down
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
113 changes: 113 additions & 0 deletions docs/SCANNER_REPORTS.md
Original file line number Diff line number Diff line change
@@ -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 `<details>` 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.
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
8 changes: 7 additions & 1 deletion scripts/create-pilot-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -27,14 +28,15 @@ 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'));
await build({
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;
Expand Down Expand Up @@ -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'));
Expand Down
19 changes: 17 additions & 2 deletions scripts/verify-pilot-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -73,13 +73,28 @@ 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']));
assert.equal(changes.catalogCoverage.recordCount, 4);
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());
52 changes: 52 additions & 0 deletions src/artifacts/filesystem.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<void> {
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.');
}
}
Loading
Loading