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: 1 addition & 1 deletion internal-docs/adr/technology-stack-selection.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,4 @@ This stack was chosen because:
|------|-----------|--------|------------|
| better-sqlite3 native compilation fails on exotic platforms | Low | High | Prebuild binaries cover all major platforms; fallback to `sql.js` (WASM-based) if needed |
| Playwright `page.route()` misses a header rewrite on an unusual response path (HTTP/2 server push, early hints) | Low | Medium | Covered by automated interactive/crawler tests against remote CSP-hardened sites; if a regression surfaces we can reintroduce a targeted proxy rather than restore the full dual-mode architecture |
| MCP protocol breaking changes | Low | Medium | Pin SDK version; MCP is backed by Anthropic with broad industry adoption |
| MCP protocol breaking changes | Low | Medium | Caret range (`^1.x`) on the SDK excludes breaking majors by semver contract; pin a specific version only if a minor actually regresses. Our surface is two imports (`McpServer`, `StdioServerTransport`), so exposure across minors is small. MCP is backed by Anthropic with broad industry adoption |
753 changes: 361 additions & 392 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@makerx/csp-analyser",
"version": "1.3.0",
"version": "1.3.1",
"type": "module",
"description": "Headlessly browse websites with a deny-all report-only CSP, capture violations, and generate production-ready CSP policies",
"license": "MIT",
Expand Down Expand Up @@ -49,7 +49,7 @@
"docs:preview": "vitepress preview docs"
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.29.0",
"@modelcontextprotocol/sdk": "^1.30.0",
"better-sqlite3": "12.8.0",
"playwright": "1.59.1",
"zod": "^4.0.0"
Expand Down
25 changes: 12 additions & 13 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,19 +255,16 @@ export async function performManualLogin(
// to the Node process. This is used by the beforeunload handler to
// capture the final state right before the page is destroyed — the only
// reliable way to avoid losing tokens on close.
await page.exposeFunction(
'__cspReportSessionStorage',
(origin: unknown, json: unknown) => {
try {
const entries = JSON.parse(String(json)) as Array<{ name: string; value: string }>;
if (entries.length > 0) {
sessionStorageSnapshots.set(String(origin), entries);
}
} catch {
// Malformed data from browser — ignore
await page.exposeFunction('__cspReportSessionStorage', (origin: unknown, json: unknown) => {
try {
const entries = JSON.parse(String(json)) as Array<{ name: string; value: string }>;
if (entries.length > 0) {
sessionStorageSnapshots.set(String(origin), entries);
}
},
);
} catch {
// Malformed data from browser — ignore
}
});

// Install in-page reporters via addInitScript so they survive navigation.
// page.evaluate() only runs on the current document and is lost when the
Expand Down Expand Up @@ -316,7 +313,9 @@ export async function performManualLogin(
}
};

page.on('load', () => { captureFromPage().catch(() => {}); });
page.on('load', () => {
captureFromPage().catch(() => {});
});

const snapshotInterval = setInterval(() => {
captureFromPage().catch(() => {});
Expand Down
4 changes: 3 additions & 1 deletion src/crawler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ async function triggerLateResources(page: Page): Promise<void> {
// browser skipped the implicit favicon request.
const iconHrefs = new Set<string>();
document
.querySelectorAll<HTMLLinkElement>('link[rel~="icon"], link[rel~="shortcut"], link[rel~="apple-touch-icon"]')
.querySelectorAll<HTMLLinkElement>(
'link[rel~="icon"], link[rel~="shortcut"], link[rel~="apple-touch-icon"]',
)
.forEach((l) => {
if (l.href) iconHrefs.add(l.href);
});
Expand Down
12 changes: 6 additions & 6 deletions src/csp-passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,7 @@ export interface CapturedCspHeader {
headerValue: string;
}

export type CspHeaderCaptureCallback = (
captured: CapturedCspHeader[],
requestUrl: string,
) => void;
export type CspHeaderCaptureCallback = (captured: CapturedCspHeader[], requestUrl: string) => void;

/**
* Appends report-uri and report-to directives to an existing CSP header value.
Expand Down Expand Up @@ -156,8 +153,11 @@ export async function setupCspPassthrough(
const response = await route.fetch();
const originalHeaders = response.headers();

const { headers: newHeaders, capturedCspHeaders, permissionsPolicies } =
transformResponseHeadersForAudit(originalHeaders, reportServerPort, reportToken);
const {
headers: newHeaders,
capturedCspHeaders,
permissionsPolicies,
} = transformResponseHeadersForAudit(originalHeaders, reportServerPort, reportToken);

if (capturedCspHeaders.length > 0 && onCspHeaders) {
onCspHeaders(capturedCspHeaders, route.request().url());
Expand Down
6 changes: 4 additions & 2 deletions src/db/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,11 +613,13 @@ export function insertInlineHash(
if (result.changes === 0) {
// Row already exists — backfill content if ours is non-null and theirs is null
if (storedContent != null) {
db.prepare(`
db.prepare(
`
UPDATE inline_hashes
SET content = ?, content_length = ?
WHERE session_id = ? AND directive = ? AND hash = ? AND content IS NULL
`).run(storedContent, p.contentLength, p.sessionId, p.directive, p.hash);
`,
).run(storedContent, p.contentLength, p.sessionId, p.directive, p.hash);
}
return null;
}
Expand Down
4 changes: 3 additions & 1 deletion src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,9 @@ function migrateViolationsUniqueConstraint(db: Database.Database): void {

// If the table DDL doesn't contain a table-level UNIQUE, no migration needed.
// The new schema creates violations without an inline UNIQUE.
if (!tableInfo.sql.includes('UNIQUE(session_id, document_uri, blocked_uri, effective_directive)')) {
if (
!tableInfo.sql.includes('UNIQUE(session_id, document_uri, blocked_uri, effective_directive)')
) {
return;
}

Expand Down
6 changes: 5 additions & 1 deletion src/policy-formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,11 @@ resource cspRule 'Microsoft.Cdn/profiles/ruleSets/rules@2024-09-01' = {
const directiveEntries = Object.entries(helmetDirectives)
.map(([key, sources]) => {
const values = sources
.map((s) => (s === '$$NONCE_FN$$' ? '(req, res) => `\'nonce-${res.locals.cspNonce}\'`' : JSON.stringify(s)))
.map((s) =>
s === '$$NONCE_FN$$'
? "(req, res) => `'nonce-${res.locals.cspNonce}'`"
: JSON.stringify(s),
)
.join(', ');
return ` ${key}: [${values}],`;
})
Expand Down
14 changes: 11 additions & 3 deletions src/report-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,17 @@ export async function startReportServer(
}

const remaining = violationLimit > 0 ? violationLimit - violationCount : Infinity;
handleReport(req, res, db, sessionId, req.url === cspReportPath, remaining, (count: number) => {
violationCount += count;
});
handleReport(
req,
res,
db,
sessionId,
req.url === cspReportPath,
remaining,
(count: number) => {
violationCount += count;
},
);
return;
}

Expand Down
31 changes: 14 additions & 17 deletions src/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,19 +476,16 @@ export async function runInteractiveSession(
const attachSessionStorageCapture = async (page: Page) => {
// Expose bridge for in-page reporters to push sessionStorage to Node
try {
await page.exposeFunction(
'__cspReportSessionStorage',
(origin: unknown, json: unknown) => {
try {
const entries = JSON.parse(String(json)) as Array<{ name: string; value: string }>;
if (entries.length > 0) {
sessionStorageSnapshots.set(String(origin), entries);
}
} catch {
// Malformed data — ignore
await page.exposeFunction('__cspReportSessionStorage', (origin: unknown, json: unknown) => {
try {
const entries = JSON.parse(String(json)) as Array<{ name: string; value: string }>;
if (entries.length > 0) {
sessionStorageSnapshots.set(String(origin), entries);
}
},
);
} catch {
// Malformed data — ignore
}
});
} catch {
// exposeFunction may fail if the page is already closed or
// if the function was already exposed on a recycled context
Expand All @@ -508,7 +505,10 @@ export async function runInteractiveSession(
}
}
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
(window as any).__cspReportSessionStorage(window.location.origin, JSON.stringify(items));
(window as any).__cspReportSessionStorage(
window.location.origin,
JSON.stringify(items),
);
/* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
};
window.addEventListener('beforeunload', report);
Expand All @@ -532,10 +532,7 @@ export async function runInteractiveSession(

// 8. Listen for additional pages (new tabs opened by the user)
context.on('page', (page: Page) => {
Promise.all([
setupPage(page),
attachSessionStorageCapture(page),
]).catch((err: unknown) => {
Promise.all([setupPage(page), attachSessionStorageCapture(page)]).catch((err: unknown) => {
logger.error('Failed to set up interactive page', {
error: err instanceof Error ? err.message : String(err),
});
Expand Down