srclawyer 0.6.1 → 0.6.2
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/README.md +2 -0
- package/dist/commands/init.js +17 -2
- package/dist/commands/scan.js +7 -1
- package/dist/engine/discoverFiles.d.ts +2 -1
- package/dist/engine/discoverFiles.js +7 -7
- package/dist/engine/discoverProjects.d.ts +9 -1
- package/dist/engine/discoverProjects.js +71 -18
- package/dist/engine/globFiles.d.ts +10 -0
- package/dist/engine/globFiles.js +12 -0
- package/dist/engine/languageDetection.d.ts +2 -1
- package/dist/engine/languageDetection.js +10 -10
- package/dist/engine/rootBoundary.d.ts +29 -0
- package/dist/engine/rootBoundary.js +102 -0
- package/dist/engine/scanEngine.js +174 -58
- package/dist/engine/scanLimits.d.ts +29 -0
- package/dist/engine/scanLimits.js +70 -0
- package/dist/engine/tsProject.d.ts +2 -1
- package/dist/engine/tsProject.js +40 -3
- package/dist/engine/types.d.ts +16 -0
- package/dist/engine/workspacePatterns.d.ts +23 -0
- package/dist/engine/workspacePatterns.js +140 -0
- package/dist/policy/generatePolicy.js +33 -8
- package/dist/report/formatText.js +16 -0
- package/dist/rules/htmlRules.d.ts +11 -0
- package/dist/rules/htmlRules.js +73 -17
- package/dist/rules/zodPreFilter.js +10 -1
- package/dist/schemaParsers/openapi.d.ts +6 -1
- package/dist/schemaParsers/openapi.js +3 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -90,6 +90,8 @@ Every finding traces back to a file and line (`src/engine/types.ts#Finding`).
|
|
|
90
90
|
- **Data sent to a third party via a raw HTTP call isn't attributed to that processor.** SDK-integration detection (above) is anchored to known package imports. A call like Python's `requests.post("https://api.some-provider.com/...", json={...})` or JS's `fetch("https://api.some-provider.com/...")` — bypassing a dedicated SDK entirely — is invisible to this detection today, in both languages, regardless of how sensitive the data it sends is.
|
|
91
91
|
- **Python support is based on one real-world corpus repository so far** (a Flask app; see `test-fixtures/golden-corpus/`), not the broader validation JS detection has had. Expect rougher edges on Flask codebases with unusual patterns.
|
|
92
92
|
- **OpenAPI/Swagger scanning reports every schema under `components.schemas`, including response-only ones** — a schema that's only ever returned by the server (never submitted by a client) gets identical treatment to a real request-input schema, since nothing currently checks whether a schema is actually reachable from an operation's `requestBody` or `parameters`. On a large, schema-heavy spec this can mean a long "Items Requiring Manual Review" list that includes fields the product never collects, only ever returns. Separately, a schema defined **inline** inside a `requestBody` (not as a named, `$ref`'d `components.schemas` entry) isn't read at all today, in either direction — only named component schemas are scanned.
|
|
93
|
+
- **Flask support covers direct request reads and same-function aliases only.** It detects `request.form[...]`, `request.args.get(...)`, `request.json[...]` and the like, plus one assignment hop within a function (`data = request.get_json()`, then `data['x']`). Form classes — Flask-WTF/WTForms `FlaskForm` subclasses with `StringField`/`PasswordField` declarations read via `form.email.data` — are not detected, and neither are fields rendered by template helpers such as `{{ form.email() }}`. A Flask app that collects data through WTForms may therefore show fewer collected fields than it actually has.
|
|
94
|
+
- **Server-side template syntax inside HTML is not analyzed.** Jinja, Django, ERB and similar delimiters (`{{`, `{%`, `<%`, `{#`) are not interpreted: an inline `<script>` that isn't valid JavaScript until the template engine runs is skipped (listed in `--json` as `template-syntax`, with a one-line note), and form fields that a template generates rather than spells out as `<input>` tags are not seen.
|
|
93
95
|
|
|
94
96
|
## Network calls
|
|
95
97
|
|
package/dist/commands/init.js
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
4
|
import { CONFIG_FILENAME, DEFAULT_CONFIG, configPath, hasUnmigratedLegacyConfig, legacyConfigMessage, writeConfig, } from "../config/config.js";
|
|
5
5
|
import { NETWORK_CALLS_NOTICE } from "../policy/legalNotices.js";
|
|
6
|
+
import { runScan } from "./scan.js";
|
|
6
7
|
function guessIndustry(root) {
|
|
7
8
|
const pkgPath = join(root, "package.json");
|
|
8
9
|
if (!existsSync(pkgPath))
|
|
@@ -82,13 +83,18 @@ function warnIfContradictsTargetMarkets(label, finalValue, suggestedFromMarkets)
|
|
|
82
83
|
export async function runInit(root, flags = {}) {
|
|
83
84
|
const existingPath = configPath(root);
|
|
84
85
|
if (existsSync(existingPath)) {
|
|
85
|
-
console.log(`${CONFIG_FILENAME} already exists.
|
|
86
|
+
console.log(`${CONFIG_FILENAME} already exists -- left unchanged. To start over, delete it and re-run "srclawyer init":\n\n rm ${CONFIG_FILENAME}\n srclawyer init\n`);
|
|
86
87
|
return;
|
|
87
88
|
}
|
|
88
89
|
if (hasUnmigratedLegacyConfig(root)) {
|
|
89
90
|
console.log(legacyConfigMessage());
|
|
90
91
|
return;
|
|
91
92
|
}
|
|
93
|
+
// The final "run a scan now?" prompt below is interactive-only: skipped whenever this run is
|
|
94
|
+
// flag-driven (any flag was passed -- a scripted/CI invocation, even a partial one) or stdin isn't
|
|
95
|
+
// a real TTY (piped, redirected, non-interactive). Computed once, up front, rather than re-checked
|
|
96
|
+
// at the end, since nothing between here and there changes either condition.
|
|
97
|
+
const isInteractive = process.stdin.isTTY === true && Object.values(flags).every((v) => v === undefined);
|
|
92
98
|
const guessedIndustry = guessIndustry(root);
|
|
93
99
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
94
100
|
const lines = rl[Symbol.asyncIterator]();
|
|
@@ -138,7 +144,6 @@ export async function runInit(root, flags = {}) {
|
|
|
138
144
|
apiKeyEnvVar = await ask(lines, "Which environment variable holds your Anthropic API key?", "ANTHROPIC_API_KEY");
|
|
139
145
|
console.log(`\nNote: only the name "${apiKeyEnvVar}" is written to ${CONFIG_FILENAME} — the key itself is never written to disk. It's read from your environment at scan time.`);
|
|
140
146
|
}
|
|
141
|
-
rl.close();
|
|
142
147
|
const config = {
|
|
143
148
|
entityLocation: entityLocation || null,
|
|
144
149
|
targetMarkets,
|
|
@@ -160,4 +165,14 @@ export async function runInit(root, flags = {}) {
|
|
|
160
165
|
};
|
|
161
166
|
writeConfig(root, config);
|
|
162
167
|
console.log(`\nWrote ${CONFIG_FILENAME}. Run "srclawyer scan" to analyze this codebase.`);
|
|
168
|
+
let runScanNow = false;
|
|
169
|
+
if (isInteractive) {
|
|
170
|
+
const raw = await ask(lines, "Run a scan now and generate your policy?", "y");
|
|
171
|
+
runScanNow = /^y(es)?$/i.test(raw.trim());
|
|
172
|
+
}
|
|
173
|
+
rl.close();
|
|
174
|
+
if (runScanNow) {
|
|
175
|
+
console.log("");
|
|
176
|
+
await runScan(root, { json: false, out: "PRIVACY_POLICY.md", llm: true });
|
|
177
|
+
}
|
|
163
178
|
}
|
package/dist/commands/scan.js
CHANGED
|
@@ -24,6 +24,12 @@ export async function runScan(root, options) {
|
|
|
24
24
|
}
|
|
25
25
|
const result = await scan(root);
|
|
26
26
|
let findings = result.findings;
|
|
27
|
+
if (result.workspaceWarning)
|
|
28
|
+
console.error(result.workspaceWarning);
|
|
29
|
+
if (result.skippedFilesWarning)
|
|
30
|
+
console.error(result.skippedFilesWarning);
|
|
31
|
+
if (result.templateSyntaxNote)
|
|
32
|
+
console.error(result.templateSyntaxNote);
|
|
27
33
|
if (result.maxZodFragmentBytes > 0) {
|
|
28
34
|
console.error(`Largest Zod-schema fragment this scan: ${result.maxZodFragmentBytes} bytes (hard ceiling: ${MAX_FRAGMENT_BYTES} bytes).`);
|
|
29
35
|
}
|
|
@@ -71,7 +77,7 @@ export async function runScan(root, options) {
|
|
|
71
77
|
const finalResult = { ...result, findings, ambiguousCount };
|
|
72
78
|
const policyPath = resolve(root, options.out);
|
|
73
79
|
writeFileSync(policyPath, generatePolicyMarkdown(finalResult, config, llmError, protectedLogicWarning));
|
|
74
|
-
if (result.unsupportedStackWarning || llmError || protectedLogicWarning || legacyConfigWarning) {
|
|
80
|
+
if (result.unsupportedStackWarning || result.workspaceWarning || result.skippedFilesWarning || llmError || protectedLogicWarning || legacyConfigWarning) {
|
|
75
81
|
process.exitCode = 1;
|
|
76
82
|
}
|
|
77
83
|
if (options.json) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RootBoundary } from "./rootBoundary.js";
|
|
1
2
|
export interface DiscoveredFiles {
|
|
2
3
|
codeFiles: string[];
|
|
3
4
|
pythonFiles: string[];
|
|
@@ -5,4 +6,4 @@ export interface DiscoveredFiles {
|
|
|
5
6
|
schemaFiles: string[];
|
|
6
7
|
manifestFiles: string[];
|
|
7
8
|
}
|
|
8
|
-
export declare function discoverFiles(root: string): Promise<DiscoveredFiles>;
|
|
9
|
+
export declare function discoverFiles(root: string, boundary: RootBoundary): Promise<DiscoveredFiles>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { globFiles } from "./globFiles.js";
|
|
2
2
|
const CODE_PATTERNS = ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.mjs", "**/*.cjs"];
|
|
3
3
|
const PYTHON_PATTERNS = ["**/*.py"];
|
|
4
4
|
const HTML_PATTERNS = ["**/*.html", "**/*.htm"];
|
|
@@ -27,13 +27,13 @@ const PYTHON_IGNORE = [
|
|
|
27
27
|
"**/test_*.py",
|
|
28
28
|
"**/*_test.py",
|
|
29
29
|
];
|
|
30
|
-
export async function discoverFiles(root) {
|
|
30
|
+
export async function discoverFiles(root, boundary) {
|
|
31
31
|
const [codeFiles, pythonFiles, htmlFiles, schemaFiles, manifestFiles] = await Promise.all([
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
globFiles(CODE_PATTERNS, { cwd: root, absolute: true, ignore: IGNORE, dot: false, boundary }),
|
|
33
|
+
globFiles(PYTHON_PATTERNS, { cwd: root, absolute: true, ignore: PYTHON_IGNORE, dot: false, boundary }),
|
|
34
|
+
globFiles(HTML_PATTERNS, { cwd: root, absolute: true, ignore: IGNORE, dot: false, boundary }),
|
|
35
|
+
globFiles(SCHEMA_PATTERNS, { cwd: root, absolute: true, ignore: ["**/node_modules/**"], dot: false, boundary }),
|
|
36
|
+
globFiles(MANIFEST_PATTERNS, { cwd: root, absolute: true, ignore: ["**/node_modules/**"], dot: false, boundary }),
|
|
37
37
|
]);
|
|
38
38
|
return { codeFiles, pythonFiles, htmlFiles, schemaFiles, manifestFiles };
|
|
39
39
|
}
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import type { RootBoundary } from "./rootBoundary.js";
|
|
1
2
|
import type { ProjectManifest } from "./types.js";
|
|
2
|
-
export
|
|
3
|
+
export interface ProjectDiscovery {
|
|
4
|
+
projects: ProjectManifest[];
|
|
5
|
+
/** Non-null when the scanned repo's own workspace config was rejected (malformed, hostile, or too
|
|
6
|
+
* expensive to expand) and the default package locations were scanned instead -- surfaced on the
|
|
7
|
+
* console and in the generated policy, since the scan may then be incomplete. */
|
|
8
|
+
workspaceWarning: string | null;
|
|
9
|
+
}
|
|
10
|
+
export declare function discoverProjects(root: string, boundary: RootBoundary): Promise<ProjectDiscovery>;
|
|
3
11
|
export declare function describeProject(root: string, project: ProjectManifest): string;
|
|
@@ -1,32 +1,85 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from "node:fs";
|
|
2
2
|
import { dirname, join, relative } from "node:path";
|
|
3
|
-
import
|
|
4
|
-
|
|
3
|
+
import { globFiles } from "./globFiles.js";
|
|
4
|
+
import { validateWorkspacePatterns } from "./workspacePatterns.js";
|
|
5
|
+
const DEFAULT_WORKSPACE_PATTERNS = ["packages/*", "apps/*"];
|
|
6
|
+
function rejectedConfigWarning(reason) {
|
|
7
|
+
return `Workspace configuration in package.json was rejected (${reason}); the default package locations (${DEFAULT_WORKSPACE_PATTERNS.join(", ")}) were scanned instead, so this scan may be incomplete.`;
|
|
8
|
+
}
|
|
9
|
+
function readPackageJson(path, boundary) {
|
|
10
|
+
if (!boundary.allowsPath(path))
|
|
11
|
+
return { error: "package.json resolves outside the scanned root" };
|
|
12
|
+
try {
|
|
13
|
+
// A leading BOM (common from Windows editors) is valid in practice but not to JSON.parse.
|
|
14
|
+
const text = readFileSync(path, "utf8").replace(/^/, "");
|
|
15
|
+
return { value: JSON.parse(text) };
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return { error: "package.json could not be parsed" };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// undefined means "no workspace config at all"; anything else (including a wrong type) is handed to
|
|
22
|
+
// the validator, which rejects what isn't an array of well-formed patterns.
|
|
23
|
+
function rawWorkspaces(pkg) {
|
|
24
|
+
const workspaces = typeof pkg === "object" && pkg !== null ? pkg.workspaces : undefined;
|
|
25
|
+
if (workspaces === undefined || workspaces === null)
|
|
26
|
+
return undefined;
|
|
27
|
+
if (typeof workspaces === "object" && !Array.isArray(workspaces))
|
|
28
|
+
return workspaces.packages ?? undefined;
|
|
29
|
+
return workspaces;
|
|
30
|
+
}
|
|
31
|
+
async function globManifests(patterns, root, boundary) {
|
|
32
|
+
return globFiles(patterns.map((p) => (p.startsWith("!") ? `!${join(p.slice(1), "package.json")}` : join(p, "package.json"))), { cwd: root, absolute: true, ignore: ["**/node_modules/**"], boundary });
|
|
33
|
+
}
|
|
34
|
+
export async function discoverProjects(root, boundary) {
|
|
5
35
|
const rootPackageJsonPath = join(root, "package.json");
|
|
6
36
|
if (!existsSync(rootPackageJsonPath)) {
|
|
7
|
-
return [{ root, kind: "unknown", manifestPath: null }];
|
|
8
|
-
}
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
37
|
+
return { projects: [{ root, kind: "unknown", manifestPath: null }], workspaceWarning: null };
|
|
38
|
+
}
|
|
39
|
+
const rootProject = { root, kind: "node", manifestPath: rootPackageJsonPath };
|
|
40
|
+
const parsed = readPackageJson(rootPackageJsonPath, boundary);
|
|
41
|
+
let workspaceWarning = null;
|
|
42
|
+
let patterns = null;
|
|
43
|
+
let hasWorkspaceConfig = existsSync(join(root, "lerna.json")) || existsSync(join(root, "nx.json"));
|
|
44
|
+
if ("error" in parsed) {
|
|
45
|
+
workspaceWarning = rejectedConfigWarning(parsed.error);
|
|
46
|
+
hasWorkspaceConfig = true;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
const raw = rawWorkspaces(parsed.value);
|
|
50
|
+
if (raw !== undefined) {
|
|
51
|
+
hasWorkspaceConfig = true;
|
|
52
|
+
const validation = validateWorkspacePatterns(raw);
|
|
53
|
+
if (validation.ok)
|
|
54
|
+
patterns = validation.patterns;
|
|
55
|
+
else
|
|
56
|
+
workspaceWarning = rejectedConfigWarning(validation.reason);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
16
59
|
if (!hasWorkspaceConfig) {
|
|
17
|
-
return
|
|
60
|
+
return { projects: [rootProject], workspaceWarning: null };
|
|
61
|
+
}
|
|
62
|
+
let manifestPaths;
|
|
63
|
+
try {
|
|
64
|
+
manifestPaths = await globManifests(patterns ?? DEFAULT_WORKSPACE_PATTERNS, root, boundary);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Belt and braces behind validateWorkspacePatterns: a pattern that still fails to expand must
|
|
68
|
+
// never take the whole scan down.
|
|
69
|
+
workspaceWarning = rejectedConfigWarning("the patterns could not be expanded");
|
|
70
|
+
try {
|
|
71
|
+
manifestPaths = await globManifests(DEFAULT_WORKSPACE_PATTERNS, root, boundary);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
manifestPaths = [];
|
|
75
|
+
}
|
|
18
76
|
}
|
|
19
|
-
const patterns = workspacePatterns ?? ["packages/*", "apps/*"];
|
|
20
|
-
const manifestPaths = await fg(patterns.map((p) => join(p, "package.json")), { cwd: root, absolute: true, ignore: ["**/node_modules/**"] });
|
|
21
77
|
const projects = manifestPaths.map((manifestPath) => ({
|
|
22
78
|
root: dirname(manifestPath),
|
|
23
79
|
kind: "node",
|
|
24
80
|
manifestPath,
|
|
25
81
|
}));
|
|
26
|
-
|
|
27
|
-
return [{ root, kind: "node", manifestPath: rootPackageJsonPath }];
|
|
28
|
-
}
|
|
29
|
-
return projects;
|
|
82
|
+
return { projects: projects.length === 0 ? [rootProject] : projects, workspaceWarning };
|
|
30
83
|
}
|
|
31
84
|
export function describeProject(root, project) {
|
|
32
85
|
const rel = relative(root, project.root);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RootBoundary } from "./rootBoundary.js";
|
|
2
|
+
export interface GlobFilesOptions {
|
|
3
|
+
cwd: string;
|
|
4
|
+
absolute?: boolean;
|
|
5
|
+
ignore?: string[];
|
|
6
|
+
dot?: boolean;
|
|
7
|
+
/** When given, symlinks resolving outside the scanned root are neither walked nor returned. */
|
|
8
|
+
boundary?: RootBoundary;
|
|
9
|
+
}
|
|
10
|
+
export declare function globFiles(patterns: string | string[], options: GlobFilesOptions): Promise<string[]>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { glob } from "tinyglobby";
|
|
2
|
+
// expandDirectories: false keeps tinyglobby's matching identical to the fast-glob behavior this
|
|
3
|
+
// replaced (a pattern matches only what it names, never everything under a matched directory).
|
|
4
|
+
// tinyglobby/picomatch compiles braces to regex alternation rather than expanding them, so it has no
|
|
5
|
+
// equivalent of the `braces` package's stack-overflow/memory exposure (GHSA-vfj7-8cjw-p6xm); patterns
|
|
6
|
+
// that come from a scanned repo are still bounded first, in workspacePatterns.ts.
|
|
7
|
+
// Sorted so scan order (and therefore the order of findings in reports) doesn't depend on filesystem
|
|
8
|
+
// traversal order, which neither fast-glob nor tinyglobby guarantees.
|
|
9
|
+
export async function globFiles(patterns, options) {
|
|
10
|
+
const { boundary, ...globOptions } = options;
|
|
11
|
+
return (await glob(patterns, { expandDirectories: false, ...globOptions, ...(boundary ? { fs: boundary.globFs() } : {}) })).sort();
|
|
12
|
+
}
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
import type { RootBoundary } from "./rootBoundary.js";
|
|
2
|
+
export declare function detectUnsupportedStack(root: string, boundary: RootBoundary): Promise<string | null>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import
|
|
3
|
+
import { globFiles } from "./globFiles.js";
|
|
4
4
|
const IGNORE = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.git/**", "**/vendor/**", "**/venv/**", "**/.venv/**", "**/__pycache__/**"];
|
|
5
5
|
// Python moved from UNSUPPORTED_LANGUAGES to here once Flask detection shipped (Stage A) -- it's a
|
|
6
6
|
// supported *language* now, but only one framework within it is actually understood. A Python project
|
|
@@ -17,7 +17,7 @@ const FASTAPI_IMPORT = /\bfrom\s+fastapi\b|\bimport\s+fastapi\b/;
|
|
|
17
17
|
function bannerize(message) {
|
|
18
18
|
return `UNSUPPORTED CODEBASE — ${message} SrcLawyer only analyzes JavaScript/TypeScript, HTML, and Flask-based Python today; the findings below (if any) are incomplete and MUST NOT be treated as a full compliance scan.`;
|
|
19
19
|
}
|
|
20
|
-
async function detectFrameworkSignature(root) {
|
|
20
|
+
async function detectFrameworkSignature(root, boundary) {
|
|
21
21
|
if (existsSync(join(root, "manage.py"))) {
|
|
22
22
|
return bannerize("Django detected (manage.py found at the project root) — Django support isn't built yet.");
|
|
23
23
|
}
|
|
@@ -26,7 +26,7 @@ async function detectFrameworkSignature(root) {
|
|
|
26
26
|
if (hasGemfile && hasRailsLayout) {
|
|
27
27
|
return bannerize("Rails detected (Gemfile + a Rails app/config layout found).");
|
|
28
28
|
}
|
|
29
|
-
const protoFiles = await
|
|
29
|
+
const protoFiles = await globFiles("**/*.proto", { cwd: root, ignore: IGNORE, dot: false, boundary });
|
|
30
30
|
if (protoFiles.length > 0) {
|
|
31
31
|
return bannerize(`gRPC/Protocol Buffers detected (${protoFiles.length} .proto file(s) found) — protobuf schemas are not parsed.`);
|
|
32
32
|
}
|
|
@@ -53,10 +53,10 @@ async function detectUnsupportedPythonFramework(root, pythonFiles) {
|
|
|
53
53
|
}
|
|
54
54
|
return null;
|
|
55
55
|
}
|
|
56
|
-
async function detectDominantUnsupportedLanguage(root) {
|
|
56
|
+
async function detectDominantUnsupportedLanguage(root, boundary) {
|
|
57
57
|
const [supportedFiles, ...unsupportedGroups] = await Promise.all([
|
|
58
|
-
|
|
59
|
-
...UNSUPPORTED_LANGUAGES.map((lang) =>
|
|
58
|
+
globFiles(SUPPORTED_EXTENSIONS.map((ext) => `**/*.${ext}`), { cwd: root, ignore: IGNORE, dot: false, boundary }),
|
|
59
|
+
...UNSUPPORTED_LANGUAGES.map((lang) => globFiles(lang.extensions.map((ext) => `**/*.${ext}`), { cwd: root, ignore: IGNORE, dot: false, boundary })),
|
|
60
60
|
]);
|
|
61
61
|
const counts = UNSUPPORTED_LANGUAGES.map((lang, i) => ({ label: lang.label, count: unsupportedGroups[i].length }));
|
|
62
62
|
const dominant = counts.filter((c) => c.count > 0).sort((a, b) => b.count - a.count)[0];
|
|
@@ -64,15 +64,15 @@ async function detectDominantUnsupportedLanguage(root) {
|
|
|
64
64
|
return null;
|
|
65
65
|
return bannerize(`${dominant.label} detected as the primary language (${dominant.count} file(s), vs ${supportedFiles.length} supported file(s)).`);
|
|
66
66
|
}
|
|
67
|
-
export async function detectUnsupportedStack(root) {
|
|
68
|
-
const frameworkWarning = await detectFrameworkSignature(root);
|
|
67
|
+
export async function detectUnsupportedStack(root, boundary) {
|
|
68
|
+
const frameworkWarning = await detectFrameworkSignature(root, boundary);
|
|
69
69
|
if (frameworkWarning)
|
|
70
70
|
return frameworkWarning;
|
|
71
|
-
const pythonFiles = await
|
|
71
|
+
const pythonFiles = await globFiles("**/*.py", { cwd: root, absolute: true, ignore: IGNORE, dot: false, boundary });
|
|
72
72
|
if (pythonFiles.length > 0) {
|
|
73
73
|
const pythonFrameworkWarning = await detectUnsupportedPythonFramework(root, pythonFiles);
|
|
74
74
|
if (pythonFrameworkWarning)
|
|
75
75
|
return pythonFrameworkWarning;
|
|
76
76
|
}
|
|
77
|
-
return detectDominantUnsupportedLanguage(root);
|
|
77
|
+
return detectDominantUnsupportedLanguage(root, boundary);
|
|
78
78
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { realpath, realpathSync } from "node:fs";
|
|
2
|
+
/**
|
|
3
|
+
* The scanned root as a security boundary: a scanned repo is untrusted input, so no file whose REAL
|
|
4
|
+
* path resolves outside it may be read, and no directory symlink leaving it may be walked (a symlink
|
|
5
|
+
* to "/" or "~" would otherwise make a scan traverse the whole filesystem and report lines from files
|
|
6
|
+
* the repo's author chose). In-root symlinks, including loops that stay inside, keep working.
|
|
7
|
+
*/
|
|
8
|
+
export declare class RootBoundary {
|
|
9
|
+
readonly root: string;
|
|
10
|
+
readonly realRoot: string;
|
|
11
|
+
private readonly blocked;
|
|
12
|
+
private readonly resolved;
|
|
13
|
+
constructor(root: string);
|
|
14
|
+
isInsideRealPath(resolved: string): boolean;
|
|
15
|
+
/** Records a blocked link by its in-repo (lexical) path only -- never its target. */
|
|
16
|
+
private recordBlocked;
|
|
17
|
+
/** Read-time check, independent of how the path was discovered: the real path if it is inside the
|
|
18
|
+
* root, else null (and an out-of-root link is recorded). Unresolvable paths fail closed. Cached. */
|
|
19
|
+
resolveInside(path: string): string | null;
|
|
20
|
+
allowsPath(path: string): boolean;
|
|
21
|
+
/** `fs` overrides for tinyglobby: a symlink resolving outside the root errors out, so it is neither
|
|
22
|
+
* walked (directories) nor returned (files). */
|
|
23
|
+
globFs(): {
|
|
24
|
+
realpath: typeof realpath;
|
|
25
|
+
realpathSync: typeof realpathSync;
|
|
26
|
+
};
|
|
27
|
+
/** Relative in-repo paths of every blocked link, sorted; call after discovery and reading are done. */
|
|
28
|
+
blockedLinks(): string[];
|
|
29
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { realpath, realpathSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
// Directories whose symlinks are never scanned anyway (they are globbed with ignore patterns); a
|
|
4
|
+
// symlink out of one of these is blocked as usual but not worth a warning -- e.g. a package manager
|
|
5
|
+
// pointing node_modules entries at a shared store outside the repo.
|
|
6
|
+
const QUIET_SEGMENTS = new Set(["node_modules", ".git"]);
|
|
7
|
+
/**
|
|
8
|
+
* The scanned root as a security boundary: a scanned repo is untrusted input, so no file whose REAL
|
|
9
|
+
* path resolves outside it may be read, and no directory symlink leaving it may be walked (a symlink
|
|
10
|
+
* to "/" or "~" would otherwise make a scan traverse the whole filesystem and report lines from files
|
|
11
|
+
* the repo's author chose). In-root symlinks, including loops that stay inside, keep working.
|
|
12
|
+
*/
|
|
13
|
+
export class RootBoundary {
|
|
14
|
+
root;
|
|
15
|
+
realRoot;
|
|
16
|
+
// canonical in-repo location of the link entry -> its shortest lexical path. A link reached again
|
|
17
|
+
// through an in-root directory loop is the same link, counted once.
|
|
18
|
+
blocked = new Map();
|
|
19
|
+
resolved = new Map();
|
|
20
|
+
constructor(root) {
|
|
21
|
+
this.root = root;
|
|
22
|
+
try {
|
|
23
|
+
this.realRoot = realpathSync(root);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
this.realRoot = resolve(root);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
isInsideRealPath(resolved) {
|
|
30
|
+
return resolved === this.realRoot || resolved.startsWith(this.realRoot + sep);
|
|
31
|
+
}
|
|
32
|
+
/** Records a blocked link by its in-repo (lexical) path only -- never its target. */
|
|
33
|
+
recordBlocked(linkPath) {
|
|
34
|
+
const rel = relative(this.root, linkPath);
|
|
35
|
+
if (rel === "" || rel.startsWith(".."))
|
|
36
|
+
return;
|
|
37
|
+
if (rel.split(sep).some((segment) => QUIET_SEGMENTS.has(segment)))
|
|
38
|
+
return;
|
|
39
|
+
let canonical = rel;
|
|
40
|
+
try {
|
|
41
|
+
canonical = join(realpathSync(dirname(linkPath)), basename(linkPath));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// keep the lexical path
|
|
45
|
+
}
|
|
46
|
+
const existing = this.blocked.get(canonical);
|
|
47
|
+
if (existing === undefined || rel.length < existing.length)
|
|
48
|
+
this.blocked.set(canonical, rel);
|
|
49
|
+
}
|
|
50
|
+
/** Read-time check, independent of how the path was discovered: the real path if it is inside the
|
|
51
|
+
* root, else null (and an out-of-root link is recorded). Unresolvable paths fail closed. Cached. */
|
|
52
|
+
resolveInside(path) {
|
|
53
|
+
if (this.resolved.has(path))
|
|
54
|
+
return this.resolved.get(path);
|
|
55
|
+
let result = null;
|
|
56
|
+
try {
|
|
57
|
+
const real = realpathSync(path);
|
|
58
|
+
if (this.isInsideRealPath(real))
|
|
59
|
+
result = real;
|
|
60
|
+
else
|
|
61
|
+
this.recordBlocked(path);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
result = null;
|
|
65
|
+
}
|
|
66
|
+
this.resolved.set(path, result);
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
allowsPath(path) {
|
|
70
|
+
return this.resolveInside(path) !== null;
|
|
71
|
+
}
|
|
72
|
+
/** `fs` overrides for tinyglobby: a symlink resolving outside the root errors out, so it is neither
|
|
73
|
+
* walked (directories) nor returned (files). */
|
|
74
|
+
globFs() {
|
|
75
|
+
const checked = (path, resolved) => {
|
|
76
|
+
if (this.isInsideRealPath(resolved))
|
|
77
|
+
return null;
|
|
78
|
+
this.recordBlocked(path);
|
|
79
|
+
return "outside-root";
|
|
80
|
+
};
|
|
81
|
+
const asyncRealpath = ((path, callback) => {
|
|
82
|
+
realpath(path, (err, resolved) => {
|
|
83
|
+
if (err)
|
|
84
|
+
return callback(err);
|
|
85
|
+
const problem = checked(path, resolved);
|
|
86
|
+
callback(problem ? Object.assign(new Error(problem), { code: "EXDEV" }) : null, problem ? undefined : resolved);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
const syncRealpath = ((path) => {
|
|
90
|
+
const resolved = realpathSync(path);
|
|
91
|
+
const problem = checked(path, resolved);
|
|
92
|
+
if (problem)
|
|
93
|
+
throw Object.assign(new Error(problem), { code: "EXDEV" });
|
|
94
|
+
return resolved;
|
|
95
|
+
});
|
|
96
|
+
return { realpath: asyncRealpath, realpathSync: syncRealpath };
|
|
97
|
+
}
|
|
98
|
+
/** Relative in-repo paths of every blocked link, sorted; call after discovery and reading are done. */
|
|
99
|
+
blockedLinks() {
|
|
100
|
+
return [...this.blocked.values()].sort();
|
|
101
|
+
}
|
|
102
|
+
}
|