diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a596ed..ed576ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,17 @@ jobs: - name: SDK package builds run: npm run build:packages + - name: URL Safety Security Gate + run: npm run url-safety:security + + - name: URL Safety Benchmark + run: npm run benchmark:url-safety + + - name: URL Safety API and dependency inventory + run: | + npm run url-safety:api + npm run url-safety:sbom + - name: Native ESM package import smoke run: npm run packages:smoke @@ -129,6 +140,11 @@ jobs: benchmark-results/development/smoke-balanced.csv benchmark-results/development/external-open-license-validation.json benchmark-results/ci/bundle-cost.json + benchmark-results/development/url-safety-security.json + benchmark-results/development/url-safety-tests.json + benchmark-results/development/url-safety-benchmark.json + benchmark-results/development/url-safety-sbom.cdx.json + benchmark-results/development/url-safety-licenses.json multisymbology-unit: name: Multi-Symbology Unit Tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cd6fe5..3411b4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,13 @@ All notable changes follow semantic versioning. ## Unreleased -No changes yet. +## 2.1.0 — Link Intelligence (development; qualification pending) + +- Added optional `@scanly/url-safety` with local URL signals, canonicalization, redaction, cache, cancellation, confirmed-event integration and deterministic scoring. +- Added a Node-only server entry with pinned public-IP connections, per-hop SSRF validation, bounded inspection, reputation lookup adapters and schema-validated advisory LLM evidence. +- Added explicit privacy modes and a default-disabled demo, with a same-origin rate/size/time-limited backend. +- Existing barcode decoding remains local and independent of network analysis. Published 2.0.0 and 2.0.1 artifacts and historical evidence are unchanged. +- Release status and unresolved qualification requirements are recorded in `docs/releases/v2.1.0.md`; this entry does not assert publication. ## 2.0.1 — Scanner lifecycle and runtime hardening diff --git a/README.md b/README.md index 4866a75..14a07e8 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ -# Scanly SDK v2.0.1 +# Scanly SDK v2.1.0 — Link Intelligence (development) Privacy-first, local-only barcode scanning SDK for Web, Node.js, React, iOS, and Android. [![SDK 2.0.1](https://img.shields.io/badge/SDK-2.0.1-green)](https://github.com/Yangjunjie-Lin/Scanly/releases/tag/v2.0.1) +[![Source 2.1.0](https://img.shields.io/badge/SDK-2.1.0-green)](docs/releases/v2.1.0.md) [![npm latest](https://img.shields.io/badge/npm-latest-CB3837)](https://www.npmjs.com/package/@scanly/browser) [![TypeScript](https://img.shields.io/badge/TypeScript-5-blue)](https://www.typescriptlang.org/) [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) @@ -13,6 +14,8 @@ Privacy-first, local-only barcode scanning SDK for Web, Node.js, React, iOS, and ## Install +This branch develops 2.1.0. The published Stable release remains 2.0.1 until the new release qualification and publication gates pass. + Choose the package for your runtime. Browser, Node, and React packages already include their required Scanly workspace dependencies. ```bash @@ -21,6 +24,26 @@ npm install @scanly/node npm install @scanly/react ``` +## AI-Assisted URL Safety + +Barcode decoding remains local. Remote URL intelligence is optional and disabled by default; no barcode image or camera frame is sent to AI or reputation providers. Link Intelligence is an independent asynchronous second stage, using the existing structured payload parser. + +```ts +import { UrlSafetyClient } from "@scanly/url-safety"; + +const safetyClient = new UrlSafetyClient({ endpoint: "/api/url-safety" }); +const outcome = await router.scan(frame); +if (outcome.ok && outcome.primary.structuredPayload?.kind === "url") { + const analysis = await safetyClient.analyze( + outcome.primary.structuredPayload.fields.href as string, + { mode: "local-only" }, // no network; choose other modes only with explicit consent + ); + // Display evidence. Never automatically open the destination. +} +``` + +`@scanly/url-safety/server` provides self-hosted SSRF-protected inspection, optional Google Web Risk / VirusTotal lookup adapters, and a vendor-neutral JSON LLM adapter. LLM is advisory evidence, not an authority. No verdict guarantees a URL is safe. See [URL safety architecture, integration, privacy and configuration](docs/url-safety.md). + Advanced engine composition can use `@scanly/core` directly: ```bash diff --git a/api-snapshots/native-abi.json b/api-snapshots/native-abi.json index a658c61..1e1b469 100644 --- a/api-snapshots/native-abi.json +++ b/api-snapshots/native-abi.json @@ -1,6 +1,6 @@ { "schemaVersion": "stable-native-abi-snapshot-1", - "sdkVersion": "2.0.1", + "sdkVersion": "2.1.0", "abiVersion": 1, "exportedSymbols": [ "scanly_cancel_token_cancel", diff --git a/api-snapshots/native-api.json b/api-snapshots/native-api.json index 0c14a15..97adce8 100644 --- a/api-snapshots/native-api.json +++ b/api-snapshots/native-api.json @@ -1,6 +1,6 @@ { "schemaVersion": "stable-native-api-snapshot-1", - "sdkVersion": "2.0.1", + "sdkVersion": "2.1.0", "classification": "stable-breaking-changes-forbidden-with-explicit-compatibility-review", "files": { "native/core/include/scanly/core.h": "82dee124d9b88e8ee2c9ea6727a83a3a9e08fb6b70ca753c404928d70624d07a", diff --git a/api-snapshots/public-api.json b/api-snapshots/public-api.json index ec5fef5..9eb0cf9 100644 --- a/api-snapshots/public-api.json +++ b/api-snapshots/public-api.json @@ -3,7 +3,7 @@ "packages": [ { "packageName": "@scanly/core", - "packageVersion": "2.0.1", + "packageVersion": "2.1.0", "exports": { ".": { "types": "./dist/index.d.ts", @@ -35,7 +35,7 @@ "contracts/operator.d.ts": "18077b1053bb5156f59b92ae5906c8fba922f77bf226ca0c82bff535a00b08e8", "contracts/result.d.ts": "18477ad67be91bd65866662f7db79b5483e1da47463e1f658bb78c4157337465", "contracts/validator.d.ts": "33b21365b712c8349c0ed1a23dce445b5ce237ca9672b706e2cc63fad2a6944c", - "index.d.ts": "51deec2d8b2faed49fcc62eebf4f9c4acd28b9c0b34336ea489d03ec33c6349f", + "index.d.ts": "dfaa598baecab60b2f1d46132c8fc3342995b050bcf0cb75bba41e714ee53dcc", "industrial/candidate-region.d.ts": "a6bd9513b23a8ae25b7e368f1444fadf4a46c4399a7658608f0f2d59921d2664", "industrial/coordinate-transform.d.ts": "903561693bfb18ae88c38a8b145c2ee45d1007826a594b4736cb69e78ad07f15", "industrial/decode-candidate-conflict.d.ts": "e0d2a7806f3b1eeeef024cee8c44141338f83ee1a29131aa4c2b2f29e3c4844f", @@ -84,7 +84,7 @@ }, { "packageName": "@scanly/browser", - "packageVersion": "2.0.1", + "packageVersion": "2.1.0", "exports": { ".": { "types": "./dist/index.d.ts", @@ -102,7 +102,7 @@ "camera-strategy.d.ts": "799e0c964d6702c443caa4bc5d3d4b55a741a8733b1e35d2f260db28cd58e070", "decode-upload.d.ts": "2509ee37a22687302a4b4212d99b89e8c3506b493c649f95bd2d09375b2e35fa", "image-loader.d.ts": "ad4c9336c38ca562c043e284a8cdb832f6e7579d127df3740cbef72647a6091e", - "index.d.ts": "61ee9c06c84bb1a036cb7fa19c050f24f63a3470db6f333a6253f8c33dc52df2", + "index.d.ts": "97fdfb128ff88347b684e2d24dfcfcb0b437501cf77bc518ba4ad70dedfb60e2", "runtime.d.ts": "50be12a3ab1f49d28d15df8d1dfd2531d5888b3c6899cff1471f7a5012c99f60", "scanner/camera-capabilities.d.ts": "0a3112c32680e637965fe4ded4949de841c03ff5570fbd4672322fa2636ef140", "scanner/camera-platform.d.ts": "a82972a4d23addbf5f856ab5d9f03e5429385b1647813cb01ccd8c055cc9f266", @@ -132,7 +132,7 @@ }, { "packageName": "@scanly/node", - "packageVersion": "2.0.1", + "packageVersion": "2.1.0", "exports": { ".": { "types": "./dist/index.d.ts", @@ -146,7 +146,7 @@ }, { "packageName": "@scanly/react", - "packageVersion": "2.0.1", + "packageVersion": "2.1.0", "exports": { ".": { "types": "./dist/index.d.ts", @@ -160,7 +160,7 @@ }, { "packageName": "@scanly/scenario-schema", - "packageVersion": "2.0.1", + "packageVersion": "2.1.0", "exports": { ".": { "types": "./dist/index.d.ts", @@ -174,7 +174,7 @@ }, { "packageName": "@scanly/parsers", - "packageVersion": "2.0.1", + "packageVersion": "2.1.0", "exports": { ".": { "types": "./dist/index.d.ts", @@ -188,7 +188,7 @@ }, { "packageName": "@scanly/benchmark", - "packageVersion": "2.0.1", + "packageVersion": "2.1.0", "exports": { ".": { "types": "./dist/index.d.ts", @@ -207,7 +207,7 @@ }, { "packageName": "@scanly/engine-jsqr", - "packageVersion": "2.0.1", + "packageVersion": "2.1.0", "exports": { ".": { "types": "./dist/index.d.ts", @@ -221,7 +221,7 @@ }, { "packageName": "@scanly/engine-zxing-js", - "packageVersion": "2.0.1", + "packageVersion": "2.1.0", "exports": { ".": { "types": "./dist/index.d.ts", @@ -232,6 +232,40 @@ "declarationHashes": { "index.d.ts": "cdedf9c166132ce21bacaf31e40affed4feecb6b2f3cf7b9b2c59f47ec6b25b1" } + }, + { + "packageName": "@scanly/url-safety", + "packageVersion": "2.1.0", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "browser": null, + "node": "./dist/server/index.js" + } + }, + "types": "./dist/index.d.ts", + "declarationHashes": { + "async.d.ts": "2ef61c95d0e26ef7606b9449b6a804224cdf47f9ac7b55eb853c02caf865f27a", + "cache.d.ts": "e98f5df8c14efdb0140dd794829d7fa1e03ef702c4a60260650af9b341bb3315", + "client.d.ts": "b8cc11bdd4dbf90c88dba93e9502471829cdf0737bb4ff2cb320ee0196fe2dff", + "index.d.ts": "df1816704160bcc4bce1d6b7a6bde0ea4d62129d7340293f50cb435dab88f4e2", + "local.d.ts": "26b5c04b465910d0e4cc4475081c605df91c47385325de455b1d15c5bd1f84d9", + "risk-engine.d.ts": "e42e4cab7cf3695c61ead6d537297b96c6772684c994f2b8c25aea044cb91618", + "scanner.d.ts": "cce733b13762dcd476c4c0d57babd384c9c98834f40bb11b23c8cb5782d74135", + "server/analyzer.d.ts": "5cd269f23deee1b3fc789358d98f674858920fa5d1a45261183675b5299f424d", + "server/fetcher.d.ts": "270025b70658bdecbb006d86408f9dc433b5acbc50db9f612d15d6edbb219ebb", + "server/handler.d.ts": "291ba086b5db539ce5aa01882bb6b9ac44cf49dd96ce2ce583b20b7fceccc373", + "server/html.d.ts": "851c0a93a78e860610dc31697208f0290fc97de496f99e041363d080e9708690", + "server/index.d.ts": "9725a9ed684c0a1b39a6790768e9c475e7df6e67fdf9a872a255d7471e768cb4", + "server/ip-policy.d.ts": "b69b2b390e05928a225c2b202c080ec61c860e5a324d1c98d4c3f32dffbe69da", + "server/providers.d.ts": "07841f7b75a9c499a7e9eeb5b3625755ab924a86d071bdb6e93d476dca8e36fc", + "types.d.ts": "49760efa8c5063f12b3e23baa8de1952f3368ce5292b967b732498dca2317d4f", + "validation.d.ts": "6173f46e3fd5c20d322b31cb54d37bf40d82d1a8d277cff2acf6f5e677c66730" + } } ] } diff --git a/apps/web-demo/app/api/url-safety/route.ts b/apps/web-demo/app/api/url-safety/route.ts new file mode 100644 index 0000000..ec1c3cc --- /dev/null +++ b/apps/web-demo/app/api/url-safety/route.ts @@ -0,0 +1,18 @@ +import { createUrlSafetyAnalyzer, createUrlSafetyHandler, GoogleWebRiskProvider, VirusTotalProvider } from "@scanly/url-safety/server"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// No NEXT_PUBLIC secrets. LLM integration is a server-owned transport extension; +// see docs/url-safety.md. No hosted Scanly service is required. +const analyzer = createUrlSafetyAnalyzer({ + reputationProviders: [ + ...(process.env.SCANLY_WEB_RISK_API_KEY ? [new GoogleWebRiskProvider(process.env.SCANLY_WEB_RISK_API_KEY)] : []), + ...(process.env.SCANLY_VIRUSTOTAL_API_KEY ? [new VirusTotalProvider(process.env.SCANLY_VIRUSTOTAL_API_KEY)] : []), + ], +}); +export const POST = createUrlSafetyHandler({ + analyzer, + ...(process.env.SCANLY_URL_SAFETY_ORIGIN ? { allowedOrigins: [process.env.SCANLY_URL_SAFETY_ORIGIN] } : {}), + allowedModes: process.env.SCANLY_URL_SAFETY_ENABLED === "true" ? ["local-only", "reputation-only", "remote", "ai-assisted"] : ["local-only"], +}); diff --git a/apps/web-demo/app/layout.tsx b/apps/web-demo/app/layout.tsx index 3116a26..32605d9 100644 --- a/apps/web-demo/app/layout.tsx +++ b/apps/web-demo/app/layout.tsx @@ -4,7 +4,7 @@ import type { Metadata } from "next"; export const metadata: Metadata = { title: "Scanly — Browser QR Decoder", description: - "Scan supported barcodes with the Scanly SDK v2.0.1 Stable camera runtime or local image input — local-only, with no uploads or accounts.", + "Scan supported barcodes locally with Scanly SDK v2.1.0. URL safety intelligence is a separate, optional opt-in; barcode images and camera frames are never uploaded.", }; export default function RootLayout({ children }: { children: React.ReactNode }) { diff --git a/apps/web-demo/app/page.tsx b/apps/web-demo/app/page.tsx index 3ca7296..f6126c9 100644 --- a/apps/web-demo/app/page.tsx +++ b/apps/web-demo/app/page.tsx @@ -7,7 +7,7 @@ export default function Page() {

Scanly

- Browser-side Scanly SDK v2.0.1 barcode scanner with bounded frame scheduling, temporal confirmation, and multi-layer decoder fallbacks. Camera or + Browser-side Scanly SDK v2.1.0 barcode scanner with optional Link Intelligence, bounded frame scheduling, temporal confirmation, and multi-layer decoder fallbacks. Camera or upload — processing stays on your device.
diff --git a/apps/web-demo/components/QRTool.tsx b/apps/web-demo/components/QRTool.tsx index d1a9294..f96b256 100644 --- a/apps/web-demo/components/QRTool.tsx +++ b/apps/web-demo/components/QRTool.tsx @@ -10,7 +10,9 @@ import { createTrackOverlayModels, } from "@scanly/browser"; import type { BatchState, TrackOverlayModel } from "@scanly/browser"; -import { isSafeActionUrl } from "@scanly/parsers"; +import { isSafeActionUrl, parseSemanticPayload } from "@scanly/parsers"; +import { UrlSafetyClient, UrlSafetyController, structuredUrl, type UrlSafetyPrivacyMode, type UrlSafetyState } from "@scanly/url-safety"; +import { UrlSafetyEvidence } from "./UrlSafetyEvidence"; import type { ScanResult, SdkErrorCode } from "@scanly/core"; import { getBuiltinScenario, type ScenarioPresetId } from "@scanly/scenario-schema"; @@ -48,7 +50,7 @@ function resultFromScannerEvent(event: import("@scanly/browser").ScanEvent): Sca ...(event.barcode.rawBytes ? { rawBytes: event.barcode.rawBytes } : {}), ...(event.barcode.cornerPoints ? { cornerPoints: [...event.barcode.cornerPoints] } : {}), engine: { id: event.barcode.engineId, version: event.barcode.engineVersion ?? "unknown" }, - preprocessingPath: [], frameId: String(event.frameId), structuredPayload: null, + preprocessingPath: [], frameId: String(event.frameId), structuredPayload: parseSemanticPayload(event.barcode.text).structured, validation: { valid: true, validatorIds: [], messages: [] }, warnings: [], timing: { totalMs: 0 }, }; } @@ -91,6 +93,11 @@ export default function QRTool() { const [mode, setMode] = useState("camera"); const [status, setStatus] = useState("Idle"); const [results, setResults] = useState([]); + const [safetyMode, setSafetyMode] = useState<"disabled" | UrlSafetyPrivacyMode>("disabled"); + const [safetyState, setSafetyState] = useState({ status: "idle" }); + const safetyRef = useRef(null); + const safetyOwnerRef = useRef(undefined); + const safetyClient = useMemo(() => new UrlSafetyClient({ endpoint: "/api/url-safety" }), []); const [lastError, setLastError] = useState(""); const [errorReason, setErrorReason] = useState(""); const [devices, setDevices] = useState([]); @@ -120,6 +127,8 @@ export default function QRTool() { const batchSessionRef = useRef(null); const primaryResult = results[0]; + const safetyUrl = structuredUrl(primaryResult?.structuredPayload) ?? undefined; + const primarySafetyState = safetyState.owner === safetyUrl ? safetyState : { status: "idle" as const }; const primary = primaryResult?.rawText ?? ""; const isUrl = primary ? isSafeActionUrl(primary) : false; const retail = retailMetadata(primaryResult); @@ -132,7 +141,22 @@ export default function QRTool() { const recoveryDiagnostics = process.env.NODE_ENV !== "production" ? primaryResult?.metadata?.scannerDiagnostics : undefined; const activeScenario = useMemo(() => getBuiltinScenario(scenarioPreset(preset)), [preset]); + useEffect(() => { + const controller = new UrlSafetyController(safetyClient, (state) => setSafetyState({ ...state, owner: safetyOwnerRef.current })); + safetyRef.current = controller; + controller.configure(safetyMode === "disabled" ? undefined : safetyMode); + return () => { controller.dispose(); safetyRef.current = null; }; + }, [safetyClient, safetyMode]); + + useEffect(() => { + safetyOwnerRef.current = safetyUrl; + safetyRef.current?.accept(primaryResult?.structuredPayload); + }, [primaryResult, safetyMode, safetyUrl]); + + useEffect(() => () => safetyClient.dispose(), [safetyClient]); + async function disposeCameraRuntime(): Promise { + safetyRef.current?.cancel(); const batch = batchSessionRef.current; const scanner = scannerSessionRef.current; batchSessionRef.current = null; @@ -296,6 +320,7 @@ export default function QRTool() { } function stopScan() { + safetyRef.current?.cancel(); if (batchSessionRef.current) void batchSessionRef.current.stop(); else void scannerSessionRef.current?.stop(); // Stop any leftover media tracks @@ -312,6 +337,7 @@ export default function QRTool() { } function pauseScan() { + safetyRef.current?.cancel(); if (batchSessionRef.current) batchSessionRef.current.pause(); else scannerSessionRef.current?.pause(); setScannerState("paused"); @@ -438,6 +464,10 @@ export default function QRTool() { if (!isSafeActionUrl(text)) return; const url = new URL(text); if (url.protocol !== "http:" && url.protocol !== "https:") return; + if (safetyMode !== "disabled") { + const risk = text === primary ? primarySafetyState.analysis?.riskLevel : undefined; + if (risk !== "low" && !window.confirm(risk === "high" || risk === "critical" ? "High-risk URL. Do not enter credentials. Open anyway?" : "This URL is unverified or has risk indicators. Verify the domain before proceeding. Open with caution?")) return; + } window.open(url.href, "_blank", "noopener,noreferrer"); } @@ -744,13 +774,13 @@ export default function QRTool() { @@ -800,6 +830,8 @@ export default function QRTool() { )} + + {results.length > 1 && (
    {results.map((r, i) => ( @@ -825,7 +857,7 @@ export default function QRTool() { {isSafeActionUrl(r.rawText) && (