Skip to content

Commit 42cd63d

Browse files
committed
fix(parity): enforce fail-hard cross-engine parity and sync security docs
- tests/unit/parity.test.ts: Python engine invocation now throws on execution or parse failure instead of silently passing; shared assertCrossEngineParity() helper deduplicates both fixtures; REPODNA_ALLOW_PYTHON_PARITY_SKIP=1 remains a local-only opt-out. - ci.yml: add dedicated fail-hard parity job (Node 22 + Python 3.12, pip install -e ., vitest parity suite); visualizer unit tests set the local skip env since that job has no Python runtime. - README.md: limits table split into browser/private vs public durable columns; compression-ratio row now documents the actual quarantine-and-continue behavior; added cross-engine parity enforcement section. - SECURITY.md + docs/threat-model.md: replace stale abort/413 SUSPICIOUS_COMPRESSION_RATIO wording with the implemented quarantine semantics and public durable limits. No behavior changes.
1 parent dfb3da2 commit 42cd63d

5 files changed

Lines changed: 142 additions & 101 deletions

File tree

.github/workflows/ci.yml

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,34 @@ jobs:
3131
- run: npm ci
3232
- run: npm run lint
3333
- run: npm run typecheck
34-
- run: npm run test:unit
34+
# The dedicated `parity` job enforces cross-engine parity fail-hard with
35+
# the Python engine installed; the visualizer job has no Python runtime.
36+
- name: Unit tests (cross-engine parity enforced in dedicated parity job)
37+
run: npm run test:unit
38+
env:
39+
REPODNA_ALLOW_PYTHON_PARITY_SKIP: '1'
3540
- run: npm run build
3641
- run: npm run build:vercel
3742

43+
parity:
44+
name: Cross-engine parity (TS + Python)
45+
runs-on: ubuntu-latest
46+
steps:
47+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
48+
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
49+
with:
50+
node-version: '22'
51+
cache: npm
52+
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
53+
with:
54+
python-version: '3.12'
55+
cache: pip
56+
- run: npm ci
57+
- run: python -m pip install -e .
58+
# Fail-hard cross-engine contract: a missing Python engine, a crash, or
59+
# any TypeScript/Python output drift fails CI. No skip env var is set.
60+
- run: npx vitest run tests/unit/parity.test.ts
61+
3862
browser-smoke:
3963
runs-on: ubuntu-latest
4064
needs: visualizer

README.md

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -94,20 +94,18 @@ Exports use a private seven-day Vercel Blob cache for public commit-addressed an
9494

9595
## Enforced Resource Limits & Quotas
9696

97-
| Limit | Maximum | Action upon breach |
98-
|---|---|---|
99-
| **Public Rate Limit** | 5 analyses / 10 min per IP | Returns `429 RATE_LIMITED` |
100-
| **Authenticated Rate Limit** | 20 analyses / 10 min per user | Returns `429 RATE_LIMITED` |
101-
| **Total archive entries** | 20,000 entries | Returns `413 TOO_MANY_ARCHIVE_ENTRIES` (header bomb defense) |
102-
| **Candidate repository files** | 10,000 files | Returns `413 TOO_MANY_FILES` |
103-
| **Individual file size** | 1 MB (1,000,000 bytes) | Skipped with diagnostic (`exceeds_file_size_limit`) |
104-
| **Declared compression ratio** | 200:1 (entries > 256 KB) | Returns `413 SUSPICIOUS_COMPRESSION_RATIO` |
105-
| **Private/browser compressed archive** | 25 MB (26,214,400 bytes) | Returns `413 ARCHIVE_TOO_LARGE` |
106-
| **Private/browser extracted content** | 100 MB (104,857,600 bytes) | Returns `413 EXTRACTED_TOO_LARGE` (ZIP bomb protection) |
107-
| **Public durable compressed archive** | 128 MB before Git tree fallback | Uses bounded archive or Git tree acquisition |
108-
| **Public durable extracted content** | 192 MB | Produces an honest partial inventory when bounded limits are reached |
109-
| **Interactive graph** | 8,000 nodes / 12,000 edges in the v2 artifact; 240 rendered nodes / edges in the live canvas | Preserves full inventory and reports compaction diagnostics |
110-
| **GitHub fetch timeout** | 20 seconds private/browser; 60 seconds public durable | Returns a controlled upstream timeout/failure |
97+
| Limit | Browser / private analysis | Public durable analysis | Action upon breach |
98+
|---|---|---|---|
99+
| **Public rate limit** | 5 analyses / 10 min per IP | Same | Returns `429 RATE_LIMITED` |
100+
| **Authenticated rate limit** | 20 analyses / 10 min per user | Same | Returns `429 RATE_LIMITED` |
101+
| **Candidate repository files** | 10,000 files | 20,000 files | Browser/private returns `413 TOO_MANY_FILES`; public durable produces an honest partial inventory (`max_files_limit`) |
102+
| **Total archive entries** | 20,000 entries | 100,000 entries | Returns `413 TOO_MANY_ARCHIVE_ENTRIES` (header bomb defense) |
103+
| **Individual file size** | 1 MB (1,000,000 bytes) | Same | Skipped with diagnostic (`exceeds_file_size_limit`) |
104+
| **Declared compression ratio** | 200:1 (entries emitting > 256 KB decompressed) | Same | Entry is **quarantined, not fatal**: its stream is terminated and buffers discarded, it is skipped with a `suspicious_compression_ratio` diagnostic, and analysis of the remaining safe files continues. Every emitted byte—including quarantined entries—still counts toward the cumulative extracted-bytes cap. |
105+
| **Compressed archive download** | 25 MB (26,214,400 bytes) | 128 MB before Git-tree fallback | Browser/private returns `413 ARCHIVE_TOO_LARGE`; public durable uses bounded archive or Git-tree acquisition |
106+
| **Extracted content** | 100 MB (104,857,600 bytes) | 192 MB | Browser/private returns `413 EXTRACTED_TOO_LARGE` (ZIP bomb protection); public durable produces an honest partial inventory when bounded limits are reached |
107+
| **Interactive graph** | 8,000 nodes / 12,000 edges in the v2 artifact; 240 rendered nodes / edges in the live canvas | Same | Preserves full inventory and reports compaction diagnostics |
108+
| **GitHub fetch timeout** | 20 seconds | 60 seconds | Returns a controlled upstream timeout/failure |
111109

112110
---
113111

@@ -158,6 +156,21 @@ npm test
158156
npm run build:vercel
159157
```
160158

159+
### Cross-Engine Parity Enforcement
160+
161+
The `fastapi-basic` and `express-basic` parity cases run both the TypeScript
162+
engine and the Python engine (`core/repodna`) over the same fixtures and
163+
**fail hard** when the Python engine is unavailable, crashes, emits
164+
unparseable output, or diverges from the TypeScript engine. A missing Python
165+
implementation can never silently pass.
166+
167+
- Locally, contributors without a Python environment can set
168+
`REPODNA_ALLOW_PYTHON_PARITY_SKIP=1` to skip the Python half of the parity
169+
cases instead of failing.
170+
- CI never sets that variable: a dedicated `parity` job installs the Python
171+
engine (`python -m pip install -e .`) and enforces the contract fail-hard
172+
on every push and pull request.
173+
161174
---
162175

163176
## Canonical Contract & Security

SECURITY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ RepoDNA is architected around **Zero Code Execution**:
2929

3030
4. **Resource Bounds & Streaming Ingestion Defenses**:
3131
- Bounded download sizes with streaming network byte caps (25 MB default archive limit) that cancel downstream connections immediately upon breach.
32-
- Streaming Decompression Defense via `fflate`: Archive buffers are fed in bounded 64 KiB chunks with a hard all-entry cap (20,000 max entries) against header bombs, candidate file limits (10,000 max), path traversal rejection, null-byte path validation, path depth limits (32 max), per-entry uncompressed byte caps (1 MB max, skipping oversized entries early), declared compression-ratio heuristic guards (aborting on >200:1 ratio past 256 KB floor), and cumulative extracted content limits (100 MB max, counting all emitted bytes).
32+
- Streaming Decompression Defense via `fflate`: Archive buffers are fed in bounded 64 KiB chunks with a hard all-entry cap (20,000 max entries) against header bombs, candidate file limits (10,000 max), path traversal rejection, null-byte path validation, path depth limits (32 max), per-entry uncompressed byte caps (1 MB max, skipping oversized entries early), declared compression-ratio quarantine (>200:1 ratio past a 256 KB floor terminates and discards that entry, skipped with a `suspicious_compression_ratio` diagnostic, while analysis of the remaining safe files continues; every emitted byte—including quarantined entries—still counts toward the cumulative caps), and cumulative extracted content limits (100 MB max, counting all emitted bytes). Public durable analyses raise these bounds (20,000 files / 100,000 entries / 128 MB archive / 192 MB extracted) with honest partial inventories instead of hard failures.
3333
- Syntax parsers operate under AST depth caps (`MAX_AST_DEPTH = 128`), node budgets (`MAX_AST_NODES = 25000`), item collection limits, and automatic Tree-sitter WASM cleanup (`tree.delete()`).
3434

3535
---

docs/threat-model.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ This document outlines the threat vectors considered in the design of RepoDNA an
88

99
| Threat Vector | Description | Severity | Mitigation in RepoDNA |
1010
|---|---|---|---|
11-
| **Malicious Archive / ZIP Bomb** | Attacker crafts a ZIP with high compression ratio (e.g. 5 MB expanding to 10 GB), millions of files, or duplicate paths. | **Critical** | Streaming decompression via `fflate` with bounded ingestion: 64 KiB chunk-fed `Unzip` (`UnzipInflate`/`UnzipPassThrough`), EOCD structural validation, `maxArchiveEntries = 20,000` header-bomb cap (`TOO_MANY_ARCHIVE_ENTRIES`), `maxFiles = 10,000` candidate cap (`TOO_MANY_FILES`), duplicate normalized-path rejection, per-entry live cap `maxFileBytes = 1 MB` with early `terminate()` and discarded buffers, cumulative `maxTotalExtractedBytes = 100 MB` counting every emitted byte including skipped oversized files (`EXTRACTED_TOO_LARGE`), declared ratio heuristic >200:1 after 256 KB floor (`SUSPICIOUS_COMPRESSION_RATIO`), and `maxArchiveBytes = 25 MB` network cap with streaming `reader.cancel()`. Local folder byte limits use `File.size` before decoding so UTF-16 string length cannot under-count input. |
11+
| **Malicious Archive / ZIP Bomb** | Attacker crafts a ZIP with high compression ratio (e.g. 5 MB expanding to 10 GB), millions of files, or duplicate paths. | **Critical** | Streaming decompression via `fflate` with bounded ingestion: 64 KiB chunk-fed `Unzip` (`UnzipInflate`/`UnzipPassThrough`), EOCD structural validation, `maxArchiveEntries = 20,000` header-bomb cap (`TOO_MANY_ARCHIVE_ENTRIES`), `maxFiles = 10,000` candidate cap (`TOO_MANY_FILES`), duplicate normalized-path rejection, per-entry live cap `maxFileBytes = 1 MB` with early `terminate()` and discarded buffers, cumulative `maxTotalExtractedBytes = 100 MB` counting every emitted byte including skipped oversized files (`EXTRACTED_TOO_LARGE`), declared ratio quarantine >200:1 after 256 KB floor (entry stream `terminate()`d and buffers discarded, skipped with a `suspicious_compression_ratio` diagnostic while analysis of safe files continues—no `413` is raised for a quarantined entry), and `maxArchiveBytes = 25 MB` network cap with streaming `reader.cancel()`. These are the browser/private defaults; public durable analyses raise them to 20,000 files / 100,000 entries / 128 MB archive / 192 MB extracted with honest partial inventories (`allowPartialOnFileLimit`). Local folder byte limits use `File.size` before decoding so UTF-16 string length cannot under-count input. |
1212
| **Path Traversal / Arbitrary File Overwrite** | Archive entries contain `../../etc/passwd`, absolute paths (`/root`), or Windows drive letters (`C:\`). | **Critical** | `validatePath`/`normalizeArchivePath` reject absolute paths, drive letters, `..` segments and null bytes before extraction; path depth is capped at 32 segments and paths longer than 4,096 characters are skipped. `PATH_TRAVERSAL` fails closed at the incremental `onfile` boundary, not post-extraction. |
1313
| **Malformed or hostile analysis artifact** | A user imports a huge or schema-invalid RepoDNA JSON file, or a cached Blob is corrupted. | **High** | Imported artifacts are capped at 128 MB before reading and validated through the version-aware schema loader. The CSP-safe browser validator checks every graph node and edge with a bounded error list. Cached public artifacts are size checked and schema validated before use; malformed entries are evicted. |
1414
| **Malicious Syntax / Parser Bomb** | Deeply nested ASTs, 1 MB single-line JS strings, or recursive macros designed to crash memory/CPU. | **High** | Tree-sitter budgets `MAX_AST_DEPTH = 128`, `MAX_AST_NODES = 25,000` with item collection limits and guaranteed `tree.delete()` cleanup. Syntax parsing runs in try-catch with graceful degradation (`parseSuccessRate` reduction and diagnostics) without failing the whole repo. Future: wall-clock analysis budget / Worker isolation. |

tests/unit/parity.test.ts

Lines changed: 88 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest';
55
import { analyzeRepositoryFiles } from '../../app/lib/analyzer';
66
import { validateRepoDNAProject } from '../../app/lib/schema/validator';
77
import type { DiscoveredFile } from '../../app/lib/analyzer/types';
8+
import type { RepoDNAProject } from '../../app/lib/types';
89

910
function readFixtureFiles(fixtureDir: string): DiscoveredFile[] {
1011
const baseDir = path.resolve(process.cwd(), fixtureDir);
@@ -33,99 +34,102 @@ function readFixtureFiles(fixtureDir: string): DiscoveredFile[] {
3334
return results;
3435
}
3536

36-
describe('Cross-Engine Conformance & Schema Parity Suite', () => {
37-
it('verifies schema validity and parity on fastapi-basic fixture', async () => {
38-
const fixturePath = 'tests/fixtures/fastapi-basic';
39-
const files = readFixtureFiles(fixturePath);
40-
41-
// 1. TypeScript Engine Analysis
42-
const tsResult = await analyzeRepositoryFiles(
43-
{ name: 'fastapi-basic', source: `file://${fixturePath}`, files, skipped: [] },
44-
{}
45-
);
46-
47-
// Validate TS schema conformance
48-
const tsValidation = validateRepoDNAProject(tsResult);
49-
expect(tsValidation.valid).toBe(true);
50-
expect(tsValidation.errors).toEqual([]);
51-
52-
// 2. Python Engine Analysis via CLI
53-
let pyResult: RepoDNAProject | null = null;
54-
try {
55-
const pyJson = execSync(
56-
`python -c "import sys, json; sys.path.insert(0, 'core'); from repodna.engine import analyze_repository; res = analyze_repository('${fixturePath}'); print(json.dumps(res.to_dict()))"`,
57-
{ encoding: 'utf-8' }
58-
);
59-
pyResult = JSON.parse(pyJson);
60-
} catch (e) {
61-
console.warn('Python engine parity check skipped or failed:', e);
62-
}
63-
64-
if (pyResult) {
65-
// Validate Python schema conformance
66-
const pyValidation = validateRepoDNAProject(pyResult);
67-
expect(pyValidation.valid).toBe(true);
68-
expect(pyValidation.errors).toEqual([]);
69-
70-
// 3. Structural Parity Assertions
71-
expect(tsResult.schemaVersion).toBe(pyResult.schemaVersion);
72-
expect(tsResult.repository.sourceFileCount).toBe(pyResult.repository.sourceFileCount);
73-
74-
// Verify routes parity
75-
const tsRoutes = tsResult.routes.map((r) => `${r.method} ${r.path}`).sort();
76-
const pyRoutes = pyResult.routes.map((r) => `${r.method} ${r.path}`).sort();
77-
expect(tsRoutes).toEqual(pyRoutes);
37+
/**
38+
* Cross-engine parity is a fail-hard architectural invariant: if the Python
39+
* engine cannot be executed, crashes, emits unparseable output, or diverges
40+
* from the TypeScript engine, the parity case FAILS.
41+
*
42+
* Local development escape hatch only: contributors without a Python
43+
* environment may set REPODNA_ALLOW_PYTHON_PARITY_SKIP=1 to skip the Python
44+
* half of a parity case instead of failing. CI never sets this variable —
45+
* the dedicated `parity` workflow job enforces the contract fail-hard.
46+
*/
47+
const ALLOW_PYTHON_PARITY_SKIP = process.env.REPODNA_ALLOW_PYTHON_PARITY_SKIP === '1';
48+
49+
function runPythonEngineAnalysis(fixturePath: string): RepoDNAProject {
50+
const pyJson = execSync(
51+
`python -c "import sys, json; sys.path.insert(0, 'core'); from repodna.engine import analyze_repository; res = analyze_repository('${fixturePath}'); print(json.dumps(res.to_dict()))"`,
52+
{ encoding: 'utf-8' }
53+
);
54+
return JSON.parse(pyJson) as RepoDNAProject;
55+
}
7856

79-
// Verify framework detection parity
80-
expect(tsResult.repository.fingerprint.frameworks).toContain('FastAPI');
81-
expect(pyResult.repository.fingerprint.frameworks).toContain('FastAPI');
57+
interface ParityCase {
58+
fixtureName: string;
59+
expectedFramework: string;
60+
expectArchitectureComponents: boolean;
61+
}
8262

83-
// Verify architecture component count
84-
expect(tsResult.architecture.components.length).toBeGreaterThan(0);
85-
expect(pyResult.architecture.components.length).toBeGreaterThan(0);
63+
async function assertCrossEngineParity(parityCase: ParityCase, ctx: { skip: () => void }): Promise<void> {
64+
const fixturePath = `tests/fixtures/${parityCase.fixtureName}`;
65+
const files = readFixtureFiles(fixturePath);
66+
67+
// 1. TypeScript Engine Analysis
68+
const tsResult = await analyzeRepositoryFiles(
69+
{ name: parityCase.fixtureName, source: `file://${fixturePath}`, files, skipped: [] },
70+
{}
71+
);
72+
73+
// Validate TS schema conformance
74+
const tsValidation = validateRepoDNAProject(tsResult);
75+
expect(tsValidation.valid).toBe(true);
76+
expect(tsValidation.errors).toEqual([]);
77+
78+
// 2. Python Engine Analysis via CLI — fail-hard on any invocation or parse
79+
// failure so a missing Python engine can never silently pass parity.
80+
let pyResult: RepoDNAProject;
81+
try {
82+
pyResult = runPythonEngineAnalysis(fixturePath);
83+
} catch (e) {
84+
if (ALLOW_PYTHON_PARITY_SKIP) {
85+
console.warn(`REPODNA_ALLOW_PYTHON_PARITY_SKIP=1: skipping Python engine parity for '${parityCase.fixtureName}':`, e);
86+
ctx.skip();
8687
}
87-
});
88+
throw new Error(
89+
`Python engine invocation failed for fixture '${parityCase.fixtureName}'. ` +
90+
'Cross-engine parity is enforced fail-hard; install the Python engine (python -m pip install -e .) ' +
91+
'or set REPODNA_ALLOW_PYTHON_PARITY_SKIP=1 to skip locally.',
92+
{ cause: e }
93+
);
94+
}
8895

89-
it('verifies schema validity and parity on express-basic fixture', async () => {
90-
const fixturePath = 'tests/fixtures/express-basic';
91-
const files = readFixtureFiles(fixturePath);
96+
// Validate Python schema conformance
97+
const pyValidation = validateRepoDNAProject(pyResult);
98+
expect(pyValidation.valid).toBe(true);
99+
expect(pyValidation.errors).toEqual([]);
92100

93-
// 1. TypeScript Engine Analysis
94-
const tsResult = await analyzeRepositoryFiles(
95-
{ name: 'express-basic', source: `file://${fixturePath}`, files, skipped: [] },
96-
{}
97-
);
101+
// 3. Structural Parity Assertions
102+
expect(tsResult.schemaVersion).toBe(pyResult.schemaVersion);
103+
expect(tsResult.repository.sourceFileCount).toBe(pyResult.repository.sourceFileCount);
98104

99-
const tsValidation = validateRepoDNAProject(tsResult);
100-
expect(tsValidation.valid).toBe(true);
101-
expect(tsValidation.errors).toEqual([]);
102-
103-
// 2. Python Engine Analysis
104-
let pyResult: RepoDNAProject | null = null;
105-
try {
106-
const pyJson = execSync(
107-
`python -c "import sys, json; sys.path.insert(0, 'core'); from repodna.engine import analyze_repository; res = analyze_repository('${fixturePath}'); print(json.dumps(res.to_dict()))"`,
108-
{ encoding: 'utf-8' }
109-
);
110-
pyResult = JSON.parse(pyJson);
111-
} catch (e) {
112-
console.warn('Python engine parity check skipped or failed:', e);
113-
}
105+
// Verify routes parity
106+
const tsRoutes = tsResult.routes.map((r) => `${r.method} ${r.path}`).sort();
107+
const pyRoutes = pyResult.routes.map((r) => `${r.method} ${r.path}`).sort();
108+
expect(tsRoutes).toEqual(pyRoutes);
114109

115-
if (pyResult) {
116-
const pyValidation = validateRepoDNAProject(pyResult);
117-
expect(pyValidation.valid).toBe(true);
118-
expect(pyValidation.errors).toEqual([]);
110+
// Verify framework detection parity
111+
expect(tsResult.repository.fingerprint.frameworks).toContain(parityCase.expectedFramework);
112+
expect(pyResult.repository.fingerprint.frameworks).toContain(parityCase.expectedFramework);
119113

120-
expect(tsResult.schemaVersion).toBe(pyResult.schemaVersion);
121-
expect(tsResult.repository.sourceFileCount).toBe(pyResult.repository.sourceFileCount);
114+
// Verify architecture component count
115+
if (parityCase.expectArchitectureComponents) {
116+
expect(tsResult.architecture.components.length).toBeGreaterThan(0);
117+
expect(pyResult.architecture.components.length).toBeGreaterThan(0);
118+
}
119+
}
122120

123-
const tsRoutes = tsResult.routes.map((r) => `${r.method} ${r.path}`).sort();
124-
const pyRoutes = pyResult.routes.map((r) => `${r.method} ${r.path}`).sort();
125-
expect(tsRoutes).toEqual(pyRoutes);
121+
describe('Cross-Engine Conformance & Schema Parity Suite', () => {
122+
it('verifies schema validity and parity on fastapi-basic fixture', async (ctx) => {
123+
await assertCrossEngineParity(
124+
{ fixtureName: 'fastapi-basic', expectedFramework: 'FastAPI', expectArchitectureComponents: true },
125+
ctx
126+
);
127+
});
126128

127-
expect(tsResult.repository.fingerprint.frameworks).toContain('Express');
128-
expect(pyResult.repository.fingerprint.frameworks).toContain('Express');
129-
}
129+
it('verifies schema validity and parity on express-basic fixture', async (ctx) => {
130+
await assertCrossEngineParity(
131+
{ fixtureName: 'express-basic', expectedFramework: 'Express', expectArchitectureComponents: false },
132+
ctx
133+
);
130134
});
131135
});

0 commit comments

Comments
 (0)