feat: manifest parse + tls cert chain + security policy

This commit is contained in:
kemalai committed 2026-05-04 18:28:51 +03:00
1 parent fe35b1699f
commit 4caae06805
20 files changed
+644 -19

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
# Security Policy
The FreeCrawl SEO Tool team takes security seriously. We appreciate responsible
disclosure of vulnerabilities and aim to triage all reports promptly.
---
## Supported Versions
Only the latest published release on the `main` branch receives security
fixes. Older versions are not patched — if you are on an older release,
upgrading is the recommended remediation path.
| Version | Supported |
| ------- | ------------------ |
| Latest stable (currently `0.3.x`) | Yes |
| Older `0.x` releases | No |
Release artifacts are published at:
<https://github.com/kemalai/FreeCrawl-SEO-Tool/releases>
---
## Reporting a Vulnerability
**Please do not open public GitHub issues for security vulnerabilities.**
Public disclosure before a fix is published puts users at risk.
### Preferred channel — GitHub Security Advisories (private)
Report privately via GitHub's built-in coordinated-disclosure flow:
1. Go to <https://github.com/kemalai/FreeCrawl-SEO-Tool/security/advisories>
2. Click **"Report a vulnerability"**
3. Fill in the advisory form with reproduction steps and impact
This creates a private discussion visible only to the maintainers. We will
coordinate a fix, prepare a CVE if applicable, and credit the reporter
(unless you request anonymity) when the patched release goes public.
### Alternative — Email
If you cannot use GitHub Security Advisories, email the maintainer at:
**`kemalacarofficial@gmail.com`**
Use the subject prefix `[FreeCrawl Security]` so the report is routed
correctly. PGP encryption is not currently set up; do not include
exploit code in plain email — link to a private gist or password-protect
an attachment instead.
---
## What to Include in a Report
To help us triage quickly, please include as much of the following as
possible:
- **Affected component** — crawler engine (`@freecrawl/core`), database
layer (`@freecrawl/db`), desktop shell, MCP server, or CLI
- **Affected version(s)** — output of `Help → About`, or the `version`
field in `package.json`
- **Operating system + architecture** — Windows 11 / macOS 14 (Intel or
ARM64) / Linux distro
- **Reproduction steps** — minimal sequence that triggers the issue
- **Proof-of-concept** — screenshot, video, or minimal code
- **Impact assessment** — confidentiality / integrity / availability
exposure, privilege escalation, data exfiltration, etc.
- **Suggested fix** if you have one (entirely optional)
---
## Response Timeline
We aim for the following timeline. Real-world response times may vary
because the project is currently maintained by a single contributor.
| Stage | Target time |
| ---------------------- | ---------------- |
| Initial acknowledgement | within 5 business days |
| Triage + severity assessment | within 14 business days |
| Patch development | severity-dependent (critical: ≤ 30 days, high: ≤ 60 days, medium: ≤ 90 days) |
| Public disclosure | coordinated with reporter — typically after a patched release is published |
If you do not receive an acknowledgement within 7 business days, please
follow up — your initial report may have been lost in spam filtering.
---
## Scope
### In scope
The following are considered valid security issues:
- **Remote code execution** in the crawler, parser, or MCP server
triggered by malicious server responses (HTML, sitemap XML, robots.txt,
HTTP headers, redirect chains, manifest JSON, etc.)
- **Path traversal / arbitrary file write** through project-file
(`.seoproject`) handling, export paths, or import flows
- **SQL injection** in the SQLite layer (`@freecrawl/db`) — all user
input must flow through prepared statements
- **Cross-site scripting (XSS)** in the renderer process when displaying
crawled content (titles, anchor text, meta descriptions, custom
extraction results)
- **Sandbox escape** from the renderer to the main process bypassing
contextBridge isolation
- **Credential leakage** — API keys, project paths, or crawl data
accidentally written to logs, telemetry, or remote services
- **Denial-of-service** on a normal site crawl that the user could
reasonably expect to complete (e.g. catastrophic memory growth on a
modestly-sized site, parser infinite loops, deadlocks under normal
concurrency)
- **Supply-chain compromise** of distributed installers or the GitHub
Actions release workflow
### Out of scope
- **DoS via clearly-malicious target sites** — if a user manually points
the crawler at a hostile server, transient resource exhaustion is
expected; report only if it persists after the crawl is stopped
- **Issues that require physical access** to the user's machine
- **Self-XSS** that requires the user to paste attacker-controlled
content into devtools
- **Reports based on outdated versions** without a current-version
reproduction
- **Best-practice / hardening suggestions without a concrete impact**
(e.g. "you should add a CSP header") — please open a regular GitHub
issue or pull request instead
- **Vulnerabilities in third-party dependencies** that are already
publicly disclosed and have an upstream patch in flight — we
appreciate the heads-up, but the report should ideally be filed with
the upstream project; we will pull in patched versions on the next
routine release
- **Social-engineering attacks** against the maintainer
---
## Distribution & Code-Signing
FreeCrawl SEO Tool releases are distributed exclusively through GitHub
Releases. The artifacts are produced by an automated GitHub Actions
workflow defined in `.github/workflows/release.yml`. SHA512 digests are
published alongside each release in `latest.yml` / `latest-mac.yml`.
Code-signing for Windows installers is being established through
[SignPath.io](https://signpath.io)'s open-source signing program. Once
active, all `.exe` artifacts will be signed and the application will
verify the publisher signature before applying any in-app update. macOS
artifacts are currently unsigned; users should verify the SHA512 digest
manually until Apple Developer ID notarization is in place.
If you encounter an installer that fails signature verification or
appears to come from any source other than the official GitHub Releases
page above, **do not run it** — please report it via the channels above.
---
## Cryptographic Material & Secrets
- **No secrets are bundled in the application.** API keys for optional
third-party integrations (Search Console, Analytics, AI providers,
etc.) are stored locally via the OS-native keychain when configured by
the user, and are never transmitted to FreeCrawl-controlled servers.
- **No telemetry is collected** — the application does not phone home,
beacon, or report usage data. The only outbound network requests are
the crawls the user explicitly initiates and (when enabled) the
on-demand "Check for Updates" call to the GitHub Releases API.
- **Project files (`.seoproject`)** are SQLite databases containing
crawled content. They are not encrypted at rest in the current
release — users handling sensitive data should rely on full-disk
encryption at the OS level.
---
## Acknowledgements
We are happy to credit security researchers who report valid issues
through the channels above, both in the affected release's CHANGELOG
and (for higher-severity issues) in the published GitHub Security
Advisory. If you prefer to remain anonymous, please mention that in
your report.
Thank you for helping keep FreeCrawl SEO Tool and its users safe.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/cli",
"version": "0.3.2",
"version": "0.3.3",
"private": true,
"type": "module",
"bin": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/desktop",
"version": "0.3.2",
"version": "0.3.3",
"private": true,
"description": "FreeCrawl SEO — desktop application",
"main": "./out/main/index.js",
+2
View File
@@ -2134,6 +2134,8 @@ function registerIpc(): void {
subject: null,
signatureAlgorithm: null,
protocol: null,
chainLength: null,
chainSubjects: null,
probeStatus: -1,
probeError: null,
probedAt: null,
@@ -861,6 +861,14 @@ function NameValueView({ row }: { row: CrawlUrlRow }) {
['TLS Cert Issuer', cert?.issuer ?? null],
['TLS Cert Subject', cert?.subject ?? null],
['TLS Cert Signature Alg', cert?.signatureAlgorithm ?? null],
[
'TLS Cert Chain',
cert?.chainLength
? cert.chainSubjects && cert.chainSubjects.length > 0
? `${cert.chainLength} cert(s): ${cert.chainSubjects.join(' ← ')}`
: `${cert.chainLength} cert(s)`
: null,
],
['TLS Cert Valid From', cert?.validFrom ?? null],
['TLS Cert Valid To', cert?.validTo ?? null],
['TLS Cert Status', certExpiryLabel],
@@ -903,6 +911,11 @@ function NameValueView({ row }: { row: CrawlUrlRow }) {
['Apple Touch Icon', row.appleTouchIcon],
['Android / PWA Icon', row.androidIcon],
['Web Manifest', row.manifestUrl],
['Manifest Theme Color', row.manifestThemeColor],
['Manifest Short Name', row.manifestShortName],
['Manifest Display Mode', row.manifestDisplay],
['Manifest Scope', row.manifestScope],
['Manifest Icon Count', row.manifestIconCount > 0 ? row.manifestIconCount : null],
['RSS / Atom Feed', row.feedUrl],
['Title Tag Count', row.titleCount > 1 ? row.titleCount : null],
['Empty-Alt Images', row.imagesEmptyAlt > 0 ? row.imagesEmptyAlt : null],
@@ -85,6 +85,7 @@ interface FormState {
// duplicates
nearDuplicateHammingThreshold: string;
duplicatesOnlyIndexable: boolean;
dedupePreNormalize: boolean;
// custom extraction
customExtractionRules: CustomExtractionRule[];
// webhook
@@ -319,6 +320,7 @@ function configToForm(c: CrawlConfig): FormState {
processPriority: c.processPriority,
nearDuplicateHammingThreshold: String(c.nearDuplicateHammingThreshold),
duplicatesOnlyIndexable: c.duplicatesOnlyIndexable,
dedupePreNormalize: c.dedupePreNormalize ?? true,
customExtractionRules: (c.customExtractionRules ?? []).map((r) => ({ ...r })),
webhookUrl: c.webhookUrl ?? '',
auth: { ...(c.auth ?? { type: 'none' }) },
@@ -474,6 +476,7 @@ export function SettingsDialog({ open, onClose }: Props) {
),
),
duplicatesOnlyIndexable: form.duplicatesOnlyIndexable,
dedupePreNormalize: form.dedupePreNormalize,
customExtractionRules: form.customExtractionRules
.filter((r) => r.name.trim() && r.selector.trim())
.slice(0, 10),
@@ -1919,6 +1922,13 @@ function DuplicatesPanel({ form, update }: PanelProps) {
info="When on, pages with noindex / canonicalised / robots-blocked indexability are excluded from clustering — the Near-Duplicate report then surfaces only issues that affect search visibility."
example="ON for SEO audits (the typical case). Turn OFF to also cluster paginated / canonical-blocked variants for completeness."
/>
<Bool
label="Normalise URLs before duplicate-URL check"
checked={form.dedupePreNormalize}
onChange={(v) => update('dedupePreNormalize', v)}
info="When ON (default), the Duplicate URL filter compares URLs after lowercasing the host, dropping the query string, and trimming the trailing slash — the canonical SEO behaviour. When OFF, comparison is byte-exact, so the filter only fires on rows that share an identical raw URL string (rare since URLs are deduped at insert time)."
example="ON for SEO audits. OFF only when you specifically need to inspect raw-URL collisions (e.g. case-sensitive filesystem CMSes)."
/>
</div>
<div className="rounded border border-surface-800 bg-surface-950/40 p-3 text-[10px] text-surface-500">
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/mcp-server",
"version": "0.3.2",
"version": "0.3.3",
"private": true,
"type": "module",
"bin": {
+8 -8
View File
@@ -1,12 +1,12 @@
{
"name": "freecrawl-seo",
"version": "0.3.2",
"version": "0.3.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "freecrawl-seo",
"version": "0.3.2",
"version": "0.3.3",
"license": "MIT",
"workspaces": [
"apps/*",
@@ -25,7 +25,7 @@
},
"apps/cli": {
"name": "@freecrawl/cli",
"version": "0.3.2",
"version": "0.3.3",
"dependencies": {
"@freecrawl/core": "*",
"@freecrawl/db": "*",
@@ -42,7 +42,7 @@
},
"apps/desktop": {
"name": "@freecrawl/desktop",
"version": "0.3.2",
"version": "0.3.3",
"dependencies": {
"@freecrawl/core": "*",
"@freecrawl/db": "*",
@@ -84,7 +84,7 @@
},
"apps/mcp-server": {
"name": "@freecrawl/mcp-server",
"version": "0.3.2",
"version": "0.3.3",
"dependencies": {
"@freecrawl/db": "*",
"@freecrawl/shared-types": "*"
@@ -8732,7 +8732,7 @@
},
"packages/core": {
"name": "@freecrawl/core",
"version": "0.3.2",
"version": "0.3.3",
"dependencies": {
"@freecrawl/db": "*",
"@freecrawl/shared-types": "*",
@@ -8758,7 +8758,7 @@
},
"packages/db": {
"name": "@freecrawl/db",
"version": "0.3.2",
"version": "0.3.3",
"dependencies": {
"@freecrawl/shared-types": "*"
},
@@ -8770,7 +8770,7 @@
},
"packages/shared-types": {
"name": "@freecrawl/shared-types",
"version": "0.3.2",
"version": "0.3.3",
"devDependencies": {
"rimraf": "^6.0.1",
"typescript": "^5.7.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "freecrawl-seo",
"version": "0.3.2",
"version": "0.3.3",
"private": true,
"description": "Open-source desktop SEO crawler — a free alternative to Screaming Frog",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/core",
"version": "0.3.2",
"version": "0.3.3",
"private": true,
"type": "module",
"main": "./dist/index.js",
+77
View File
@@ -658,6 +658,9 @@ export class Crawler extends EventEmitter {
await yieldToEventLoop();
this.setOp('post-crawl:tls-probes');
await this.runTlsCertProbes();
await yieldToEventLoop();
this.setOp('post-crawl:manifest-probes');
await this.runManifestProbes();
this.running = false;
this.stopMemoryMonitor();
// Release per-URL dedup sets — at 1M URLs this is ~80–120 MB of string
@@ -838,6 +841,76 @@ export class Crawler extends EventEmitter {
* crawls have at most a few unique hosts and the cost is dominated by
* the handshake round-trip, not throughput.
*/
/**
* Post-crawl pass: GET each declared `<link rel="manifest">` URL once
* and stamp parsed fields onto every URL that referenced it. Manifest
* URLs are usually site-wide singletons (one fetch covers thousands
* of pages) so concurrency is small. Failures store an error string
* in the per-URL `manifest_json` column so the next crawl re-probes.
*/
private async runManifestProbes(): Promise<void> {
if (!this.config.probeManifestJson) return;
if (this.stopped) return;
let urls: string[] = [];
try {
urls = this.db.unprobedManifestUrls(2_000);
} catch (err) {
this.emit(
'info',
`manifest probe skipped (DB query failed): ${
err instanceof Error ? err.message : String(err)
}`,
);
return;
}
if (urls.length === 0) return;
this.emit('info', `Probing ${urls.length} web manifest(s)…`);
const { probeManifest } = await import('./manifest-probe.js');
const concurrency = 4;
let cursor = 0;
let probed = 0;
let failed = 0;
const worker = async (): Promise<void> => {
while (!this.stopped) {
const idx = cursor++;
if (idx >= urls.length) return;
const url = urls[idx];
if (!url) return;
try {
const result = await probeManifest(url, {
userAgent: this.resolveUserAgent(url),
acceptLanguage: this.config.acceptLanguage,
customHeaders: this.config.customHeaders,
auth: this.config.auth,
});
this.db.setManifestForReferrers({
manifestUrl: url,
rawJson: result.error ? null : result.rawJson,
themeColor: result.themeColor,
shortName: result.shortName,
display: result.display,
scope: result.scope,
iconCount: result.iconCount,
});
if (result.error) failed++;
probed++;
} catch {
failed++;
}
}
};
const workers: Promise<void>[] = [];
for (let i = 0; i < concurrency; i++) workers.push(worker());
await Promise.all(workers);
this.emit(
'info',
`Manifest probe complete: ${probed - failed}/${probed} parsed, ${failed} failed`,
);
}
private async runTlsCertProbes(): Promise<void> {
if (!this.config.probeTlsCerts) return;
if (this.stopped) return;
@@ -888,6 +961,8 @@ export class Crawler extends EventEmitter {
subject: info.subject,
signatureAlgorithm: info.signatureAlgorithm,
protocol: info.protocol,
chainLength: info.chainLength,
chainSubjects: info.chainSubjects,
probeStatus: info.error ? 0 : 200,
probeError: info.error,
});
@@ -908,6 +983,8 @@ export class Crawler extends EventEmitter {
subject: null,
signatureAlgorithm: null,
protocol: null,
chainLength: null,
chainSubjects: null,
probeStatus: 0,
probeError: err instanceof Error ? err.message : String(err),
});
+104
View File
@@ -0,0 +1,104 @@
import { fetch as undiciFetch } from 'undici';
import { defaultRequestHeaders } from './http-client.js';
import type { CrawlConfig } from '@freecrawl/shared-types';
/**
* Parsed Web App Manifest fields surfaced for the Detail panel + audits.
* Captures the most-frequently-checked PWA / install metadata; the raw
* JSON body is preserved separately so power users can read every
* custom field without us needing a column for each.
*/
export interface ManifestProbeResult {
/** Raw JSON body, capped at 4 KB to keep the column tiny on hostile manifests. */
rawJson: string | null;
themeColor: string | null;
shortName: string | null;
display: string | null;
scope: string | null;
iconCount: number;
/** Free-form error string when the fetch / parse fails. Null on success. */
error: string | null;
}
const RAW_JSON_CAP = 4096;
const PROBE_TIMEOUT_MS = 8_000;
/**
* Fetch a Web App Manifest URL and surface a small set of canonical
* fields. We deliberately keep this best-effort:
* - JSON parse failures don't throw — caller stores `error`
* - 4xx / 5xx still resolve with `error` set so the result can be
* persisted (otherwise we'd re-probe on every post-crawl pass)
* - Body cap of 4 KB protects against accidentally-streamed huge
* manifests (some CMSes proxy a 5 MB JSON-LD by mistake)
*/
export async function probeManifest(
manifestUrl: string,
config: Pick<CrawlConfig, 'userAgent' | 'acceptLanguage' | 'customHeaders' | 'auth'>,
): Promise<ManifestProbeResult> {
const empty: ManifestProbeResult = {
rawJson: null,
themeColor: null,
shortName: null,
display: null,
scope: null,
iconCount: 0,
error: null,
};
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
try {
const res = await undiciFetch(manifestUrl, {
method: 'GET',
headers: defaultRequestHeaders(
config.userAgent,
config.acceptLanguage,
config.customHeaders,
config.auth,
),
redirect: 'follow',
signal: controller.signal,
});
if (!res.ok) {
return { ...empty, error: `HTTP ${res.status}` };
}
const text = await res.text();
const truncated = text.slice(0, RAW_JSON_CAP);
let parsed: Record<string, unknown> | null = null;
try {
const body = JSON.parse(text) as unknown;
if (body && typeof body === 'object' && !Array.isArray(body)) {
parsed = body as Record<string, unknown>;
} else {
return { ...empty, rawJson: truncated, error: 'manifest is not a JSON object' };
}
} catch (err) {
return {
...empty,
rawJson: truncated,
error: `JSON parse error: ${err instanceof Error ? err.message : String(err)}`,
};
}
const stringField = (k: string): string | null => {
const v = parsed![k];
return typeof v === 'string' && v.trim() ? v.trim() : null;
};
const icons = parsed['icons'];
return {
rawJson: truncated,
themeColor: stringField('theme_color'),
shortName: stringField('short_name'),
display: stringField('display'),
scope: stringField('scope'),
iconCount: Array.isArray(icons) ? icons.length : 0,
error: null,
};
} catch (err) {
return {
...empty,
error: err instanceof Error ? err.message : String(err),
};
} finally {
clearTimeout(timer);
}
}
+45 -4
View File
@@ -15,6 +15,20 @@ export interface TlsCertInfo {
signatureAlgorithm: string | null;
/** Negotiated TLS protocol version, e.g. `TLSv1.3`. */
protocol: string | null;
/**
* Number of certificates the server presented in its chain (leaf
* inclusive). 1 means leaf-only / self-signed; 2 means leaf + root
* with no intermediate; ≥3 indicates one or more intermediate CAs.
* Null on probe failure.
*/
chainLength: number | null;
/**
* Distinguished names of the certs in the chain, leaf-first. Capped
* at 5 entries to keep the column tiny — corporate PKIs occasionally
* present long chains and we don't need the full path for the audit.
* Null on probe failure.
*/
chainSubjects: string[] | null;
/** Empty on success. Free-form error string when the connect/handshake fails. */
error: string | null;
}
@@ -44,6 +58,8 @@ export async function probeTlsCert(
subject: null,
signatureAlgorithm: null,
protocol: null,
chainLength: null,
chainSubjects: null,
error: null,
};
@@ -76,14 +92,37 @@ export async function probeTlsCert(
() => {
clearTimeout(timer);
try {
// `getPeerCertificate(false)` returns just the leaf cert — what
// we need for expiry/issuer. `(true)` would walk the chain but
// we don't surface chain info in V1.
const peer = socket.getPeerCertificate(false) as PeerCertificate;
// `getPeerCertificate(true)` returns the leaf cert with its
// `issuerCertificate` chain attached so we can surface chain
// length + intermediate CA subjects alongside the leaf
// expiry/issuer/signature data.
const peer = socket.getPeerCertificate(true) as PeerCertificate;
if (!peer || Object.keys(peer).length === 0) {
finish({ ...empty, error: 'peer certificate unavailable' });
return;
}
// Walk the chain via `issuerCertificate` until we hit a
// self-signed cert (issuerCertificate === self) or run out.
// Capped at 5 hops to defend against weird PKIs that loop.
const chainSubjects: string[] = [];
let chainLength = 0;
{
let cursor: PeerCertificate | undefined = peer;
const seen = new Set<PeerCertificate>();
while (cursor && !seen.has(cursor) && chainLength < 5) {
seen.add(cursor);
chainLength++;
const sub = formatDistinguishedName(
(cursor as { subject?: Record<string, string> }).subject ?? null,
);
if (sub) chainSubjects.push(sub);
const next: PeerCertificate | undefined = (
cursor as { issuerCertificate?: PeerCertificate }
).issuerCertificate;
if (!next || next === cursor) break;
cursor = next;
}
}
const validTo = (peer as { valid_to?: string }).valid_to ?? null;
const validFrom = (peer as { valid_from?: string }).valid_from ?? null;
let daysUntilExpiry: number | null = null;
@@ -111,6 +150,8 @@ export async function probeTlsCert(
(peer as { signatureAlgorithm?: string }).signatureAlgorithm ??
null,
protocol,
chainLength,
chainSubjects: chainSubjects.length > 0 ? chainSubjects : null,
error: null,
});
} catch (err) {
+5
View File
@@ -100,6 +100,11 @@ const XML_COLUMNS: (keyof CrawlUrlRow)[] = [
'appleTouchIcon',
'androidIcon',
'manifestUrl',
'manifestThemeColor',
'manifestShortName',
'manifestDisplay',
'manifestScope',
'manifestIconCount',
'feedUrl',
'metaRefresh',
'metaRefreshUrl',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/db",
"version": "0.3.2",
"version": "0.3.3",
"private": true,
"type": "module",
"main": "./dist/index.js",
+54
View File
@@ -1082,6 +1082,60 @@ const MIGRATIONS: Migration[] = [
db.exec('ALTER TABLE urls ADD COLUMN android_icon TEXT');
},
},
{
version: 51,
name: 'add_manifest_json_and_cert_chain_columns',
// V1 Faz 1 final bundle:
// urls.manifest_json — raw parsed manifest JSON (string,
// ~2 KB cap recommended at write time)
// so the Detail panel can show every
// custom field without us shaping
// a column for each.
// urls.manifest_theme_color — `theme_color` shorthand for the
// Stack / Server color tinting in
// the URL Details panel.
// urls.manifest_short_name — `short_name` (home-screen label).
// urls.manifest_display — `display` (e.g. `standalone`).
// urls.manifest_scope — `scope` (PWA install scope URL).
// urls.manifest_icon_count — number of icons in the parsed manifest.
//
// host_certs.chain_length — total certificates in the peer-cert
// chain (1 = self-signed, 2 = leaf
// + root, ≥3 = intermediate(s) +
// root). Surfaces partial / missing
// chain misconfigurations.
// host_certs.chain_subjects — JSON-stringified subject DN list,
// root-first order (max 5 hops).
up: (db) => {
const urlCols = db.prepare('PRAGMA table_info(urls)').all() as unknown as {
name: string;
}[];
const hasUrl = (n: string) => urlCols.some((c) => c.name === n);
if (!hasUrl('manifest_json'))
db.exec('ALTER TABLE urls ADD COLUMN manifest_json TEXT');
if (!hasUrl('manifest_theme_color'))
db.exec('ALTER TABLE urls ADD COLUMN manifest_theme_color TEXT');
if (!hasUrl('manifest_short_name'))
db.exec('ALTER TABLE urls ADD COLUMN manifest_short_name TEXT');
if (!hasUrl('manifest_display'))
db.exec('ALTER TABLE urls ADD COLUMN manifest_display TEXT');
if (!hasUrl('manifest_scope'))
db.exec('ALTER TABLE urls ADD COLUMN manifest_scope TEXT');
if (!hasUrl('manifest_icon_count'))
db.exec(
'ALTER TABLE urls ADD COLUMN manifest_icon_count INTEGER NOT NULL DEFAULT 0',
);
const certCols = db
.prepare('PRAGMA table_info(host_certs)')
.all() as unknown as { name: string }[];
const hasCert = (n: string) => certCols.some((c) => c.name === n);
if (!hasCert('chain_length'))
db.exec('ALTER TABLE host_certs ADD COLUMN chain_length INTEGER');
if (!hasCert('chain_subjects'))
db.exec('ALTER TABLE host_certs ADD COLUMN chain_subjects TEXT');
},
},
];
export function runMigrations(db: DatabaseSync): void {
+99
View File
@@ -153,6 +153,12 @@ interface UrlRowDb {
og_site_name: string | null;
og_locale: string | null;
android_icon: string | null;
manifest_json: string | null;
manifest_theme_color: string | null;
manifest_short_name: string | null;
manifest_display: string | null;
manifest_scope: string | null;
manifest_icon_count: number;
}
interface ImageRowDb {
@@ -3749,6 +3755,8 @@ export class ProjectDb {
subject: string | null;
signatureAlgorithm: string | null;
protocol: string | null;
chainLength?: number | null;
chainSubjects?: string[] | null;
probeStatus: number;
probeError: string | null;
}): void {
@@ -3757,10 +3765,12 @@ export class ProjectDb {
`INSERT INTO host_certs (
host, port, valid_from, valid_to, days_until_expiry,
issuer, subject, signature_algorithm, protocol,
chain_length, chain_subjects,
probe_status, probe_error, probed_at
) VALUES (
:host, :port, :valid_from, :valid_to, :days_until_expiry,
:issuer, :subject, :signature_algorithm, :protocol,
:chain_length, :chain_subjects,
:probe_status, :probe_error, CURRENT_TIMESTAMP
)
ON CONFLICT(host) DO UPDATE SET
@@ -3771,6 +3781,8 @@ export class ProjectDb {
subject = excluded.subject,
signature_algorithm = excluded.signature_algorithm,
protocol = excluded.protocol,
chain_length = excluded.chain_length,
chain_subjects = excluded.chain_subjects,
probe_status = excluded.probe_status,
probe_error = excluded.probe_error,
probed_at = CURRENT_TIMESTAMP`,
@@ -3785,11 +3797,74 @@ export class ProjectDb {
subject: input.subject,
signature_algorithm: input.signatureAlgorithm,
protocol: input.protocol,
chain_length: input.chainLength ?? null,
chain_subjects:
input.chainSubjects && input.chainSubjects.length > 0
? JSON.stringify(input.chainSubjects)
: null,
probe_status: input.probeStatus,
probe_error: input.probeError,
});
}
/**
* Return distinct manifest URLs referenced by indexable HTML 2xx
* pages that don't yet have manifest data filled in. The post-crawl
* `runManifestProbes` pass walks this list once.
*/
unprobedManifestUrls(limit = 1000): string[] {
const rows = this.db
.prepare(
`SELECT DISTINCT manifest_url AS u
FROM urls
WHERE manifest_url IS NOT NULL
AND manifest_url != ''
AND is_external = 0
AND content_kind = 'html'
AND status_code >= 200 AND status_code < 300
AND (manifest_json IS NULL OR manifest_json = '')
LIMIT ?`,
)
.all(limit) as { u: string }[];
return rows.map((r) => r.u).filter((u) => typeof u === 'string' && u.length > 0);
}
/**
* Stamp parsed manifest fields onto every URL that referenced this
* `manifest_url`. A single manifest is typically shared site-wide,
* so this is a one-fetch fan-out write.
*/
setManifestForReferrers(input: {
manifestUrl: string;
rawJson: string | null;
themeColor: string | null;
shortName: string | null;
display: string | null;
scope: string | null;
iconCount: number;
}): void {
this.db
.prepare(
`UPDATE urls
SET manifest_json = :json,
manifest_theme_color = :theme,
manifest_short_name = :short_name,
manifest_display = :display,
manifest_scope = :scope,
manifest_icon_count = :icons
WHERE manifest_url = :url`,
)
.run({
url: input.manifestUrl,
json: input.rawJson,
theme: input.themeColor,
short_name: input.shortName,
display: input.display,
scope: input.scope,
icons: input.iconCount,
});
}
/**
* Look up the cached TLS cert info for a single URL — joins the URL's
* host (parsed from the stored URL) against `host_certs`. Returns null
@@ -3804,6 +3879,8 @@ export class ProjectDb {
subject: string | null;
signatureAlgorithm: string | null;
protocol: string | null;
chainLength: number | null;
chainSubjects: string[] | null;
probeStatus: number;
probeError: string | null;
probedAt: string | null;
@@ -3812,6 +3889,7 @@ export class ProjectDb {
.prepare(
`SELECT hc.host, hc.valid_from, hc.valid_to, hc.days_until_expiry,
hc.issuer, hc.subject, hc.signature_algorithm, hc.protocol,
hc.chain_length, hc.chain_subjects,
hc.probe_status, hc.probe_error, hc.probed_at
FROM urls u
JOIN host_certs hc
@@ -3829,12 +3907,25 @@ export class ProjectDb {
subject: string | null;
signature_algorithm: string | null;
protocol: string | null;
chain_length: number | null;
chain_subjects: string | null;
probe_status: number;
probe_error: string | null;
probed_at: string | null;
}
| undefined;
if (!row) return null;
let chainSubjects: string[] | null = null;
if (row.chain_subjects) {
try {
const parsed = JSON.parse(row.chain_subjects) as unknown;
if (Array.isArray(parsed) && parsed.every((s) => typeof s === 'string')) {
chainSubjects = parsed as string[];
}
} catch {
/* leave null on parse failure */
}
}
return {
host: row.host,
validFrom: row.valid_from,
@@ -3844,6 +3935,8 @@ export class ProjectDb {
subject: row.subject,
signatureAlgorithm: row.signature_algorithm,
protocol: row.protocol,
chainLength: row.chain_length ?? null,
chainSubjects,
probeStatus: row.probe_status,
probeError: row.probe_error,
probedAt: row.probed_at,
@@ -4412,6 +4505,12 @@ export class ProjectDb {
ogSiteName: r.og_site_name ?? null,
ogLocale: r.og_locale ?? null,
androidIcon: r.android_icon ?? null,
manifestJson: r.manifest_json ?? null,
manifestThemeColor: r.manifest_theme_color ?? null,
manifestShortName: r.manifest_short_name ?? null,
manifestDisplay: r.manifest_display ?? null,
manifestScope: r.manifest_scope ?? null,
manifestIconCount: r.manifest_icon_count ?? 0,
crawledAt: r.crawled_at,
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/shared-types",
"version": "0.3.2",
"version": "0.3.3",
"private": true,
"type": "module",
"main": "./dist/index.js",
+32
View File
@@ -268,6 +268,18 @@ export interface CrawlUrlRow {
ogLocale: string | null;
/** First `<link rel="icon" sizes>` ≥ 192px (or `sizes="any"`). PWA / Android home-screen icon. */
androidIcon: string | null;
/** Raw parsed Web App Manifest JSON (4 KB cap). Filled by post-crawl `runManifestProbes`. */
manifestJson: string | null;
/** Manifest `theme_color` field (hex / CSS color). */
manifestThemeColor: string | null;
/** Manifest `short_name` (home-screen label). */
manifestShortName: string | null;
/** Manifest `display` mode (`fullscreen` / `standalone` / `minimal-ui` / `browser`). */
manifestDisplay: string | null;
/** Manifest `scope` (PWA install scope URL). */
manifestScope: string | null;
/** Number of icons in the parsed manifest's `icons` array. */
manifestIconCount: number;
/** Total user-facing form inputs (input/textarea/select, excluding hidden/submit/button/image/reset). */
formInputCount: number;
/** Form inputs without label / aria-label / title (WCAG 1.3.1, 4.1.2 violation). */
@@ -708,6 +720,24 @@ export interface CrawlConfig {
* connect per host — typically a handful of probes for a site crawl.
*/
probeTlsCerts: boolean;
/**
* After the HTML crawl finishes, GET each unique declared
* `<link rel="manifest">` once and stamp the parsed `theme_color`,
* `short_name`, `display`, `scope`, and icon count onto every page
* that referenced it. Raw JSON body is preserved (4 KB cap) so the
* Detail panel can show every custom field. Default `true` — one
* fetch per unique manifest URL, typically site-wide singleton.
*/
probeManifestJson: boolean;
/**
* When the post-norm "Duplicate URL" issue filter runs, normalise
* URLs (lowercase, strip query, trim trailing slash) before
* comparing. Default `true` — the canonical SEO behaviour. Set to
* `false` for a byte-exact match (rarely useful; surfaces nothing
* because URLs are already deduped at insert time, but kept as a
* config knob for symmetry with the URL-rewrite preview).
*/
dedupePreNormalize: boolean;
/**
* Hostnames (lowercase, no scheme/port) that should be treated as
* "same host" for scope purposes — used to keep CDN-served subdomains
@@ -1411,6 +1441,8 @@ export const DEFAULT_CRAWL_CONFIG: CrawlConfig = {
probeImageSizes: true,
largeImageBytes: 102_400,
probeTlsCerts: true,
probeManifestJson: true,
dedupePreNormalize: true,
cdnHosts: [],
maxLinksPerPage: 100,
maxResponseTimeMs: 0,
+4
View File
@@ -427,6 +427,10 @@ export interface UrlCertInfoResult {
subject: string | null;
signatureAlgorithm: string | null;
protocol: string | null;
/** Total certificates in the peer chain (1 = self-signed, 2 = leaf+root, ≥3 = with intermediates). */
chainLength: number | null;
/** Distinguished names of the certs in the chain, leaf-first, capped at 5 entries. */
chainSubjects: string[] | null;
/** 200 = handshake OK + cert read, 0 = error/timeout, -1 = no probe yet. */
probeStatus: number;
probeError: string | null;