reposec 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SECURITY.md ADDED
@@ -0,0 +1,29 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ RepoSec is pre-1.0 software. Security fixes are provided for the latest release on the `main` branch.
6
+
7
+ | Version | Supported |
8
+ | --- | --- |
9
+ | `main` | Yes |
10
+ | `< 0.1.0` | No |
11
+
12
+ ## Reporting a Vulnerability
13
+
14
+ Please report suspected vulnerabilities through GitHub Security Advisories:
15
+
16
+ https://github.com/zanesense/reposec/security/advisories/new
17
+
18
+ If GitHub Security Advisories are unavailable, email security@zanesense.dev with a clear description, affected component, reproduction steps, and any relevant proof of concept.
19
+
20
+ Do not open a public issue for vulnerabilities that could put users or repositories at risk.
21
+
22
+ ## Response Timeline
23
+
24
+ - Initial acknowledgement: within 2 business days.
25
+ - Triage and severity assessment: within 5 business days.
26
+ - Remediation target for confirmed high or critical issues: within 14 calendar days.
27
+ - Remediation target for confirmed medium or low issues: within 30 calendar days.
28
+
29
+ We will keep reporters updated if investigation or remediation needs more time.
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { createRequire } from "node:module";
6
+
7
+ const require = createRequire(import.meta.url);
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const tsxCli = require.resolve("tsx/cli");
10
+ const entrypoint = path.join(__dirname, "..", "scripts", "reposec-cli.mts");
11
+ const result = spawnSync(process.execPath, [tsxCli, entrypoint, ...process.argv.slice(2)], {
12
+ stdio: "inherit",
13
+ });
14
+
15
+ if (result.error) {
16
+ console.error(result.error.message);
17
+ process.exit(1);
18
+ }
19
+
20
+ process.exit(result.status ?? 1);
@@ -0,0 +1,100 @@
1
+ import type { Finding, RepoFile } from "./types";
2
+
3
+ interface BaselineJson {
4
+ ignore?: Array<
5
+ | string
6
+ | {
7
+ id?: string;
8
+ fingerprint?: string;
9
+ file?: string;
10
+ line?: number;
11
+ title?: string;
12
+ }
13
+ >;
14
+ }
15
+
16
+ function parseIgnoreLines(content: string): string[] {
17
+ return content
18
+ .split(/\r?\n/)
19
+ .map((line) => line.trim())
20
+ .filter((line) => line && !line.startsWith("#"));
21
+ }
22
+
23
+ function parseBaselineJson(content: string): BaselineJson | null {
24
+ try {
25
+ const parsed = JSON.parse(content) as BaselineJson;
26
+ if (!parsed || !Array.isArray(parsed.ignore)) return null;
27
+ return parsed;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function findingLocation(finding: Finding): string | null {
34
+ if (!finding.file) return null;
35
+ return finding.line ? `${finding.file}:${finding.line}` : finding.file;
36
+ }
37
+
38
+ function matchesStringToken(finding: Finding, token: string): boolean {
39
+ return (
40
+ finding.id === token ||
41
+ finding.fingerprint === token ||
42
+ finding.file === token ||
43
+ findingLocation(finding) === token ||
44
+ finding.title === token
45
+ );
46
+ }
47
+
48
+ export function applyRepoBaseline(
49
+ findings: Finding[],
50
+ files: RepoFile[],
51
+ ): { findings: Finding[]; suppressed: number } {
52
+ const tokens = new Set<string>();
53
+ const structured: NonNullable<BaselineJson["ignore"]> = [];
54
+
55
+ for (const file of files) {
56
+ if (file.path === ".reposecignore") {
57
+ for (const token of parseIgnoreLines(file.content)) tokens.add(token);
58
+ }
59
+ if (file.path === "reposec-baseline.json" || file.path === ".reposec-baseline.json") {
60
+ const parsed = parseBaselineJson(file.content);
61
+ if (!parsed) continue;
62
+ for (const item of parsed.ignore ?? []) {
63
+ if (typeof item === "string") tokens.add(item);
64
+ else structured.push(item);
65
+ }
66
+ }
67
+ }
68
+
69
+ if (tokens.size === 0 && structured.length === 0) {
70
+ return { findings, suppressed: 0 };
71
+ }
72
+
73
+ const kept: Finding[] = [];
74
+ let suppressed = 0;
75
+ for (const finding of findings) {
76
+ let ignore = false;
77
+ for (const token of tokens) {
78
+ if (matchesStringToken(finding, token)) {
79
+ ignore = true;
80
+ break;
81
+ }
82
+ }
83
+ if (!ignore) {
84
+ for (const item of structured) {
85
+ if (typeof item === "string") continue;
86
+ if (item.id && item.id !== finding.id) continue;
87
+ if (item.fingerprint && item.fingerprint !== finding.fingerprint) continue;
88
+ if (item.file && item.file !== finding.file) continue;
89
+ if (typeof item.line === "number" && item.line !== finding.line) continue;
90
+ if (item.title && item.title !== finding.title) continue;
91
+ ignore = true;
92
+ break;
93
+ }
94
+ }
95
+ if (ignore) suppressed++;
96
+ else kept.push(finding);
97
+ }
98
+
99
+ return { findings: kept, suppressed };
100
+ }
@@ -0,0 +1,202 @@
1
+ import { promises as dns } from "node:dns";
2
+ import net from "node:net";
3
+ import type { RepoFile } from "./types";
4
+
5
+ const MAX_HTML_BYTES = 750_000;
6
+ const MAX_ASSET_BYTES = 1_500_000;
7
+ const MAX_SCRIPT_ASSETS = 30;
8
+ const MAX_SOURCE_MAPS = 10;
9
+ const FETCH_TIMEOUT_MS = 8_000;
10
+
11
+ function isPrivateIpv4(address: string): boolean {
12
+ const parts = address.split(".").map((part) => Number(part));
13
+ if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n))) return true;
14
+ const [a, b] = parts;
15
+ return (
16
+ a === 10 ||
17
+ a === 127 ||
18
+ a === 0 ||
19
+ (a === 169 && b === 254) ||
20
+ (a === 172 && b >= 16 && b <= 31) ||
21
+ (a === 192 && b === 168)
22
+ );
23
+ }
24
+
25
+ function isPrivateIpv6(address: string): boolean {
26
+ const lower = address.toLowerCase();
27
+ return (
28
+ lower === "::1" ||
29
+ lower.startsWith("fc") ||
30
+ lower.startsWith("fd") ||
31
+ lower.startsWith("fe80:")
32
+ );
33
+ }
34
+
35
+ function isBlockedHostname(hostname: string): boolean {
36
+ const lower = hostname.toLowerCase();
37
+ return (
38
+ lower === "localhost" ||
39
+ lower.endsWith(".localhost") ||
40
+ lower.endsWith(".local") ||
41
+ lower.endsWith(".internal")
42
+ );
43
+ }
44
+
45
+ async function assertPublicHttpsUrl(raw: string): Promise<URL | null> {
46
+ if (!raw.trim()) return null;
47
+ const trimmed = raw.trim();
48
+ const normalized = /^[a-z][a-z0-9+.-]*:/i.test(trimmed)
49
+ ? trimmed
50
+ : `https://${trimmed}`;
51
+ let url: URL;
52
+ try {
53
+ url = new URL(normalized);
54
+ } catch {
55
+ return null;
56
+ }
57
+ const allowInsecureTestScan =
58
+ process.env.REPOSEC_TEST_ALLOW_INSECURE_BUNDLE_SCAN === "1";
59
+ if (url.protocol !== "https:" && !(allowInsecureTestScan && url.protocol === "http:")) {
60
+ return null;
61
+ }
62
+ if (url.username || url.password) return null;
63
+ if (!allowInsecureTestScan && isBlockedHostname(url.hostname)) return null;
64
+
65
+ const ipVersion = net.isIP(url.hostname);
66
+ if (!allowInsecureTestScan && ipVersion === 4 && isPrivateIpv4(url.hostname)) return null;
67
+ if (!allowInsecureTestScan && ipVersion === 6 && isPrivateIpv6(url.hostname)) return null;
68
+
69
+ if (!allowInsecureTestScan && ipVersion === 0) {
70
+ try {
71
+ const addresses = await dns.lookup(url.hostname, { all: true });
72
+ if (addresses.length === 0) return null;
73
+ for (const addr of addresses) {
74
+ if (addr.family === 4 && isPrivateIpv4(addr.address)) return null;
75
+ if (addr.family === 6 && isPrivateIpv6(addr.address)) return null;
76
+ }
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+ return url;
82
+ }
83
+
84
+ function fetchWithTimeout(url: URL): Promise<Response> {
85
+ const controller = new AbortController();
86
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
87
+ return fetch(url, {
88
+ cache: "no-store",
89
+ headers: { "User-Agent": "RepoSec-Client-Bundle-Scanner" },
90
+ redirect: "follow",
91
+ signal: controller.signal,
92
+ }).finally(() => clearTimeout(timeout));
93
+ }
94
+
95
+ async function fetchText(url: URL, maxBytes: number): Promise<string | null> {
96
+ try {
97
+ const res = await fetchWithTimeout(url);
98
+ if (!res.ok) return null;
99
+ const contentLength = Number(res.headers.get("content-length") ?? "0");
100
+ if (contentLength > maxBytes) return null;
101
+ const text = await res.text();
102
+ if (text.length > maxBytes) return text.slice(0, maxBytes);
103
+ return text;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ function uniqueUrls(urls: URL[]): URL[] {
110
+ const seen = new Set<string>();
111
+ const out: URL[] = [];
112
+ for (const url of urls) {
113
+ const key = url.href;
114
+ if (seen.has(key)) continue;
115
+ seen.add(key);
116
+ out.push(url);
117
+ }
118
+ return out;
119
+ }
120
+
121
+ function discoverScriptUrls(html: string, pageUrl: URL): URL[] {
122
+ const urls: URL[] = [];
123
+ const attrPattern =
124
+ /<(?:script|link)\b[^>]+(?:src|href)\s*=\s*["']([^"']+)["'][^>]*>/gi;
125
+ let match: RegExpExecArray | null;
126
+ while ((match = attrPattern.exec(html)) !== null) {
127
+ const raw = match[1];
128
+ if (!raw || raw.startsWith("data:") || raw.startsWith("blob:")) continue;
129
+ let url: URL;
130
+ try {
131
+ url = new URL(raw, pageUrl);
132
+ } catch {
133
+ continue;
134
+ }
135
+ const lower = url.pathname.toLowerCase();
136
+ const tag = match[0].toLowerCase();
137
+ const isScript =
138
+ tag.startsWith("<script") ||
139
+ tag.includes('rel="modulepreload"') ||
140
+ tag.includes("rel='modulepreload'") ||
141
+ tag.includes('rel="preload"') ||
142
+ tag.includes("rel='preload'");
143
+ if (!isScript) continue;
144
+ if (!lower.endsWith(".js") && !lower.endsWith(".mjs")) continue;
145
+ urls.push(url);
146
+ }
147
+ return uniqueUrls(urls).slice(0, MAX_SCRIPT_ASSETS);
148
+ }
149
+
150
+ function discoverSourceMapUrl(js: string, assetUrl: URL): URL | null {
151
+ const match = js.match(/\/\/# sourceMappingURL=([^\s]+)/);
152
+ if (!match?.[1]) return null;
153
+ if (match[1].startsWith("data:")) return null;
154
+ try {
155
+ return new URL(match[1], assetUrl);
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ function syntheticPath(url: URL): string {
162
+ const pieces = url.pathname.split("/").filter(Boolean);
163
+ const last = pieces.at(-1) || "asset.js";
164
+ const dir = pieces.slice(-4, -1).join("/");
165
+ const path = dir ? `${dir}/${last}` : last;
166
+ return `client-bundle/${url.hostname}/${path}`;
167
+ }
168
+
169
+ export async function fetchClientBundleFiles(
170
+ rawSiteUrl: string | null | undefined,
171
+ ): Promise<RepoFile[]> {
172
+ if (!rawSiteUrl) return [];
173
+ const pageUrl = await assertPublicHttpsUrl(rawSiteUrl);
174
+ if (!pageUrl) return [];
175
+
176
+ const html = await fetchText(pageUrl, MAX_HTML_BYTES);
177
+ if (!html) return [];
178
+
179
+ const scripts = discoverScriptUrls(html, pageUrl);
180
+ const files: RepoFile[] = [];
181
+ const sourceMapUrls: URL[] = [];
182
+
183
+ for (const scriptUrl of scripts) {
184
+ const safeUrl = await assertPublicHttpsUrl(scriptUrl.href);
185
+ if (!safeUrl) continue;
186
+ const content = await fetchText(safeUrl, MAX_ASSET_BYTES);
187
+ if (!content) continue;
188
+ files.push({ path: syntheticPath(safeUrl), content });
189
+ const mapUrl = discoverSourceMapUrl(content, safeUrl);
190
+ if (mapUrl) sourceMapUrls.push(mapUrl);
191
+ }
192
+
193
+ for (const mapUrl of uniqueUrls(sourceMapUrls).slice(0, MAX_SOURCE_MAPS)) {
194
+ const safeUrl = await assertPublicHttpsUrl(mapUrl.href);
195
+ if (!safeUrl) continue;
196
+ const content = await fetchText(safeUrl, MAX_ASSET_BYTES);
197
+ if (!content) continue;
198
+ files.push({ path: syntheticPath(safeUrl), content });
199
+ }
200
+
201
+ return files;
202
+ }