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
39 changes: 39 additions & 0 deletions .github/workflows/sdk.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: SDK validation

on:
pull_request:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

jobs:
validate:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.11
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
- run: bun install --frozen-lockfile
- name: Install the test browser explicitly (SDK installs no browser)
run: node node_modules/playwright-core/cli.js install --with-deps chromium
- name: Select the installed Chromium for integration tests
run: >-
node --input-type=module -e "import { appendFileSync } from 'node:fs';
import { chromium } from 'playwright-core';
appendFileSync(process.env.GITHUB_ENV, 'STACKPRISM_TEST_BROWSER=' + chromium.executablePath() + '\n')"
- run: bun run test:unit
- run: bun run test:sdk
- run: bun run lint
- run: bun run typecheck
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@

## Introduction

An experimental [Node.js CLI and SDK](packages/stackprism/README.md) reuses the shared detector. Build with `bun run build:sdk`; the package is not yet published to npm.

StackPrism is a **Manifest V3** browser extension for Chrome, Edge, and Firefox that identifies the tech stack used by any webpage.

Unlike most similar tools that only scan HTML resource URLs, StackPrism runs 4 independent detection channels in parallel:
Expand Down
8 changes: 8 additions & 0 deletions baselines/bing.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"id": "bing-home",
"url": "https://www.bing.com/",
"allowedHosts": ["www.bing.com", "cn.bing.com"],
"requiredTechnologies": ["HTTPS", "JavaScript", "Open Graph"],
"timeoutMs": 30000,
"observationMs": 1000
}
111 changes: 111 additions & 0 deletions build-scripts/build-sdk.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { build } from 'esbuild'
import ts from 'typescript'
import { createHash } from 'node:crypto'
import { copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

const root = fileURLToPath(new URL('../', import.meta.url))
const packageDir = path.join(root, 'packages/stackprism')
const outdir = path.join(packageDir, 'dist')
if (path.dirname(outdir) !== packageDir || path.basename(outdir) !== 'dist') throw new Error('Invalid SDK output directory')
await rm(outdir, { recursive: true, force: true })
await mkdir(outdir, { recursive: true })
const rootPackage = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'))
const sdkPackage = JSON.parse(await readFile(path.join(packageDir, 'package.json'), 'utf8'))
const tsconfig = path.join(root, 'tsconfig.json')

// Both products share inherited defaults, prefilters, and legacy-hint semantics.
const preparation = await build({
stdin: {
contents:
"export { mergeRulePartial } from './src/core/rule-normalizer'; export { precompileRuleTree } from './src/core/rule-precompiler'",
resolveDir: root,
loader: 'ts'
},
tsconfig,
bundle: true,
write: false,
platform: 'node',
format: 'esm'
})
const { mergeRulePartial, precompileRuleTree } = await import(
`data:text/javascript;base64,${Buffer.from(preparation.outputFiles[0].text).toString('base64')}`
)
const ruleDir = path.join(root, 'public/rules')
const index = JSON.parse(await readFile(path.join(ruleDir, 'index.json'), 'utf8'))
const merged = {}
for (const file of index.files) {
const partial = JSON.parse(await readFile(path.join(ruleDir, file), 'utf8'))
precompileRuleTree(partial)
mergeRulePartial(merged, partial)
}
// These groups need background observation or network sampling, unsupported by this package.
delete merged.page.bundleLicenseLibraries
delete merged.page.dynamicTechnologies
const payload = { page: merged.page, headers: merged.headers }
const sha256 = createHash('sha256').update(JSON.stringify(payload)).digest('hex')
await writeFile(path.join(outdir, 'rules.json'), JSON.stringify({ version: rootPackage.version, sha256, ...payload }))

const browser = await build({
entryPoints: [path.join(root, 'src/core/page-detector.ts')],
tsconfig,
bundle: true,
write: false,
platform: 'browser',
format: 'iife',
globalName: 'StackPrismDetector',
target: 'es2022',
minify: true
})
// esbuild emits a strict directive; use a simple parameter list so the function remains valid JavaScript.
const detectorSource = `async (input) => { ${browser.outputFiles[0].text}\nreturn StackPrismDetector.detectPageTechnologies(input.rules, input.budgetMs); }`
await build({
entryPoints: { index: path.join(root, 'src/sdk/index.ts'), cli: path.join(root, 'src/cli/index.ts') },
tsconfig,
bundle: true,
outdir,
platform: 'node',
format: 'esm',
target: 'node22',
packages: 'external',
splitting: true,
chunkNames: 'shared-[hash]',
define: {
__STACKPRISM_DETECTOR_SOURCE__: JSON.stringify(detectorSource),
__STACKPRISM_SDK_VERSION__: JSON.stringify(sdkPackage.version)
}
})

const config = ts.readConfigFile(tsconfig, ts.sys.readFile)
if (config.error) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'))
const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, root)
const program = ts.createProgram([path.join(root, 'src/sdk/index.ts')], {
...parsed.options,
noEmit: false,
declaration: true,
emitDeclarationOnly: true,
rootDir: path.join(root, 'src'),
outDir: path.join(outdir, 'types')
})
const diagnostics = ts.getPreEmitDiagnostics(program)
if (diagnostics.length)
throw new Error(
ts.formatDiagnosticsWithColorAndContext(diagnostics, {
getCanonicalFileName: file => file,
getCurrentDirectory: () => root,
getNewLine: () => '\n'
})
)
await mkdir(path.join(outdir, 'types'), { recursive: true })
const declarations = []
const emitted = program.emit(undefined, (file, content) => {
// Internal extension declarations must not leak into the public package.
if (file.replaceAll('\\', '/').includes('/types/sdk/') && ['index.d.ts', 'types.d.ts'].includes(path.basename(file))) {
declarations.push(writeFile(path.join(outdir, 'types', path.basename(file)), content))
}
})
if (emitted.emitSkipped) throw new Error('SDK declaration emission failed')
await Promise.all(declarations)
await copyFile(path.join(root, 'LICENSE'), path.join(packageDir, 'LICENSE'))
console.log(`Built ${sdkPackage.name}@${sdkPackage.version}; rules ${rootPackage.version} sha256:${sha256}`)
116 changes: 116 additions & 0 deletions build-scripts/check-bing-baseline.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { parseArgs } from 'node:util'
import path from 'node:path'

const root = fileURLToPath(new URL('../', import.meta.url))

/** Required facts are assertions; optional signals are observations, not site-wide ground truth. */
export function evaluateBaseline(baseline, report) {
if (report.coverage.httpStatus === null || report.coverage.httpStatus < 200 || report.coverage.httpStatus >= 300) {
return { status: 'unobservable', reason: `Main document HTTP status: ${report.coverage.httpStatus}`, checks: [] }
}
const detected = new Set(report.technologies.map(technology => technology.name))
const checks = [
{ name: 'expected_host', passed: baseline.allowedHosts.includes(new URL(report.url).hostname) },
{ name: 'main_document_headers', passed: report.coverage.responseHeaders === 'main_document' },
...baseline.requiredTechnologies.map(name => ({ name, passed: detected.has(name) })),
{ name: 'subresource_headers', passed: report.coverage.subresourceHeaders === 'sampled' && report.network.observedResponses > 0 },
{ name: 'bounded_capture', passed: report.network.responses.length <= report.network.responseLimit },
{
name: 'response_accounting',
passed:
report.network.observedResponses ===
report.network.responses.length + report.network.skippedResponses + report.network.failedResponses
}
]
if (report.network.failedResponses > 0) {
return { status: 'unobservable', reason: `${report.network.failedResponses} response header reads failed`, checks }
}
return { status: checks.every(check => check.passed) ? 'pass' : 'drift', checks }
}

export function formatBaselineMarkdown(run) {
const report = run.report
const checks = run.checks.map(check => `| ${check.name} | ${check.passed ? 'PASS' : 'FAIL'} |`).join('\n')
const pageNames = report?.technologies.map(technology => technology.name).join(', ') || '—'
const networkNames =
[...new Set(report?.network.responses.flatMap(response => response.technologies.map(technology => technology.name)) ?? [])].join(
', '
) || '—'
const lines = [
'# Bing live baseline',
'',
'## 中文',
'',
`状态:**${run.status}**;采样时间:${run.capturedAt}。`,
`目标:${run.target};最终地址:${report?.url ?? '—'};HTTP:${report?.coverage.httpStatus ?? '—'}。`,
`页面/主文档:${pageNames}。`,
`子资源信号:${networkNames}。它们描述资源端点,不能直接归为主站技术。`,
...(run.reason ? [`原因:${run.reason}`] : []),
'',
'| 断言 | 结果 |',
'| --- | --- |',
checks,
'',
'## English',
'',
`Status: **${run.status}**; captured: ${run.capturedAt}.`,
`Target: ${run.target}; final URL: ${report?.url ?? '—'}; HTTP: ${report?.coverage.httpStatus ?? '—'}.`,
`Page/main document: ${pageNames}.`,
`Subresource signals: ${networkNames}. These describe resource endpoints, not necessarily the site's stack.`,
...(run.reason ? [`Reason: ${run.reason}`] : []),
'',
'| Assertion | Result |',
'| --- | --- |',
checks,
'',
'## Capture / 采集',
'',
`- SDK: ${report?.engine.version ?? '—'}; rules: ${report?.rules.version ?? '—'}; SHA-256: ${report?.rules.sha256 ?? '—'}`,
`- Environment: Node ${run.environment.node}, ${run.environment.platform}/${run.environment.arch}; installed Chrome channel`,
`- After-load window: ${run.observationMs} ms; detection duration: ${report?.durationMs ?? '—'} ms`,
`- Responses: observed ${report?.network.observedResponses ?? 0}; captured ${report?.network.responses.length ?? 0}; skipped ${report?.network.skippedResponses ?? 0}; failed ${report?.network.failedResponses ?? 0}`,
'',
'可变观察项不参与通过条件;单站点实测不代表总体准确率。',
'Optional observations do not determine pass/fail. One live site does not establish overall accuracy.',
''
]
return lines.join('\n')
}

async function main() {
const { values } = parseArgs({ options: { output: { type: 'string' }, 'executable-path': { type: 'string' } }, strict: true })
const baseline = JSON.parse(await readFile(path.join(root, 'baselines/bing.json'), 'utf8'))
const outdir = path.resolve(values.output ?? path.join(root, 'artifacts/bing'))
const run = {
baseline: baseline.id,
capturedAt: new Date().toISOString(),
target: baseline.url,
observationMs: baseline.observationMs,
environment: { node: process.versions.node, platform: process.platform, arch: process.arch }
}
try {
const { scanUrl } = await import('../packages/stackprism/dist/index.js')
run.report = await scanUrl(baseline.url, {
timeoutMs: baseline.timeoutMs,
observationMs: baseline.observationMs,
executablePath: values['executable-path']
})
Object.assign(run, evaluateBaseline(baseline, run.report))
} catch (error) {
Object.assign(run, { status: 'unobservable', reason: error.code ?? 'DETECTION_FAILED', checks: [] })
}
await mkdir(outdir, { recursive: true })
await writeFile(path.join(outdir, 'report.json'), `${JSON.stringify(run, null, 2)}\n`)
await writeFile(path.join(outdir, 'report.md'), formatBaselineMarkdown(run))
console.log(JSON.stringify({ status: run.status, checks: run.checks, output: outdir }))
process.exitCode = run.status === 'pass' ? 0 : run.status === 'drift' ? 1 : 2
}

if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
await main().catch(error => {
console.error(JSON.stringify({ status: 'unobservable', reason: error.code ?? 'BASELINE_FAILED' }))
process.exitCode = 2
})
}
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 61 additions & 0 deletions docs/baselines/bing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Bing 最小实测基线 / Minimal live baseline

## 中文

这是通道回归基线,不是 Bing 完整技术栈的权威清单。定义位于 `baselines/bing.json`;默认单测使用脱敏夹具,不依赖公网。

在仓库根目录执行:

```sh
bun run baseline:bing
```

生成 `artifacts/bing/report.json` 和中英 `report.md`,目录已被 Git 忽略。退出码 0 表示通过,1 表示预期漂移,2 表示网络/HTTP/响应头读取导致无法观察。拒绝访问、限流、超时不能解释为“网站未使用技术”。需要自定义输出路径时,构建后运行 `node build-scripts/check-bing-baseline.mjs --output <directory>`。

| 内容 | 判定方式 |
| --- | --- |
| 最终主机 | `www.bing.com` 或 `cn.bing.com`;当前环境重定向到后者 |
| 必须命中 | HTTPS、JavaScript、Open Graph,依据协议、script 和 meta 标记 |
| 通道完整性 | 主文档和子资源通道已观察、响应数量有上限且计数守恒 |
| 可变观察 | Azure CDN / Front Door、HTTP/2、ASP.NET、资源数量、耗时;不作为固定真值 |
| 不做断言 | React/Vue/IIS 是否存在、Bing 后端全貌;没有直接证据就不推断 |

2026-10-17 实测:SDK `0.2.0`,Windows x64,Node `22.19.0`,本机 Chrome。最终 `https://cn.bing.com/`,HTTP 200,8/8 断言通过;观察并采集 38 个子资源响应,跳过 0,失败 0;观察窗口 1000 ms,检测耗时 2908 ms。耗时是单次观察,不是性能基准。

| 范围 | 观察结果 | 解释 |
| --- | --- | --- |
| 页面/主文档 | Azure CDN / Front Door、HTTP/2、HTTPS、JavaScript、Open Graph | 与扩展前的 5 项一致 |
| 子资源 | Azure CDN / Front Door、ASP.NET | 新通道提供资源级证据 |
| ASP.NET | `https://cn.bing.com/web/xlsc.aspx`,XHR,HTTP 204,medium | 由端点 `.aspx` 路径命中既有规则,不能证明整个 Bing 的后端实现 |

`scanUrl` 从导航前监听,`load` 后默认继续观察 1000 ms。`observationMs` / CLI `--observe` 可设 0–5000 ms;总预算依然由 `timeoutMs` 控制。最多保留当前主框架文档的 64 个子资源响应,同时挂起的头读取不超过 64。忽略 iframe 内部和旧文档请求,不读 body、不额外发检测请求。URL 移除凭据、查询和片段,路径仍需按不可信数据处理。

检查 `network.responses` 中的 `url`、`relation`、`resourceType`、`status`、`technologies`;`same_origin` / `cross_origin` 只比较 origin,不代表组织归属。顶层 `technologies` 仍只合并原页面与主文档响应头,不混入资源服务器。`detectPage` 无法追回历史响应头,继续返回 `not_observed`。

## English

This is a channel regression baseline, not an authoritative inventory of Bing's stack. The definition lives in `baselines/bing.json`; normal tests replay a sanitized fixture without public-network access.

From the repository root:

```sh
bun run baseline:bing
```

Writes JSON and bilingual Markdown to the Git-ignored `artifacts/bing` directory. Exit 0 means pass, 1 means expected-fact drift, and 2 means unobservable due to networking, HTTP or failed header reads. Blocking, rate limits and timeouts do not mean a technology is absent. After building, use `node build-scripts/check-bing-baseline.mjs --output <directory>` to choose an output location.

| Item | Evaluation |
| --- | --- |
| Final host | `www.bing.com` or `cn.bing.com`; this environment redirects to the latter |
| Required | HTTPS, JavaScript, Open Graph, supported by protocol/script/meta evidence |
| Channel integrity | Main-document and subresource coverage, bounded responses and consistent counts |
| Variable observations | Azure CDN / Front Door, HTTP/2, ASP.NET, counts and timings; not fixed ground truth |
| No claim | Presence/absence of React, Vue or IIS, or Bing's complete backend |

Live capture on 2026-10-17: SDK `0.2.0`, Windows x64, Node `22.19.0`, installed Chrome. Final URL `https://cn.bing.com/`, HTTP 200, 8/8 assertions passed. Captured all 38 observed subresource responses, zero skipped/failed, with a 1000 ms after-load window. Detection took 2908 ms; this is one observation, not a benchmark.

The five page/main-document detections remained Azure CDN / Front Door, HTTP/2, HTTPS, JavaScript and Open Graph. The new channel found Azure edge and a medium-confidence ASP.NET signal on the XHR endpoint `https://cn.bing.com/web/xlsc.aspx` (HTTP 204). Its `.aspx` path matches an existing rule; it does not establish Bing's overall backend implementation.

`scanUrl` attaches listeners before navigation and observes for 1000 ms after load by default. `observationMs` / CLI `--observe` accepts 0–5000 ms within the overall `timeoutMs` budget. At most 64 subresource responses from the current main-frame document are retained, with at most 64 outstanding header reads. Frame internals and stale-document requests are excluded. No bodies or additional detection requests. Credentials, query and fragment are removed from network URLs; paths remain untrusted.

Read `network.responses` for URL, exact origin relation, resource type, status and technologies. Cross-origin does not imply third-party ownership. Top-level technologies still combine only the existing page and main-document header detectors. `detectPage` cannot recover historical response headers and remains `not_observed`.
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
"docs:assets": "node build-scripts/sync-docs-assets.mjs",
"docs:dev": "bun run docs:assets && vitepress dev docs",
"docs:build": "bun run docs:assets && vitepress build docs",
"docs:preview": "vitepress preview docs"
"docs:preview": "vitepress preview docs",
"build:sdk": "node build-scripts/build-sdk.mjs",
"test:sdk": "node build-scripts/build-sdk.mjs && node --test tests/sdk/*.test.mjs",
"baseline:bing": "node build-scripts/build-sdk.mjs && node build-scripts/check-bing-baseline.mjs"
},
"devDependencies": {
"@crxjs/vite-plugin": "^3",
Expand All @@ -39,7 +42,8 @@
"vite-tsconfig-paths": "^5",
"vitepress": "^1.6.4",
"vue": "^3.5.44",
"vue-tsc": "^3.3.13"
"vue-tsc": "^3.3.13",
"playwright-core": "1.64.1"
},
"dependencies": {
"vue-i18n": "^11.4.14"
Expand Down
Loading
Loading