hullproof 0.0.0-stage → 0.5.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/LICENSE +222 -0
- package/NOTICE +32 -0
- package/README.md +313 -2
- package/checks/db.firebase-rules-not-open.json +24 -0
- package/checks/db.rls-every-table.json +25 -0
- package/checks/log.no-credential-in-logs.json +30 -0
- package/checks/secrets.no-env-file-committed.json +31 -0
- package/checks/secrets.no-secret-in-public-env.json +37 -0
- package/checks/storage.firebase-rules-not-open.json +22 -0
- package/checks/supply.no-untrusted-event-in-run.json +23 -0
- package/checks/web.cors-no-reflected-origin.json +31 -0
- package/detectors/ai.json +25 -0
- package/detectors/auth.json +27 -0
- package/detectors/ci.json +19 -0
- package/detectors/containers.json +8 -0
- package/detectors/data_stores.json +38 -0
- package/detectors/frameworks.json +94 -0
- package/detectors/hosting.json +44 -0
- package/detectors/iac.json +43 -0
- package/detectors/languages.json +48 -0
- package/detectors/mobile.json +22 -0
- package/detectors/package_managers.json +39 -0
- package/detectors/payments.json +19 -0
- package/detectors/runtimes.json +17 -0
- package/dist/checks/code/admin-role.js +46 -0
- package/dist/checks/code/owner-filter.js +91 -0
- package/dist/checks/code/shared.js +84 -0
- package/dist/checks/code/webhook-verify.js +77 -0
- package/dist/checks/context.js +64 -0
- package/dist/checks/map/config-checks.js +74 -0
- package/dist/checks/map/entry-checks.js +97 -0
- package/dist/checks/map/shared.js +44 -0
- package/dist/checks/ops.js +307 -0
- package/dist/checks/pro-map.js +1 -0
- package/dist/checks/run.js +92 -0
- package/dist/checks/types.js +1 -0
- package/dist/ci/comment.js +44 -0
- package/dist/ci/sarif-check.js +157 -0
- package/dist/ci/sarif.js +61 -0
- package/dist/cli.js +358 -0
- package/dist/commands/ci.js +115 -0
- package/dist/commands/git.js +58 -0
- package/dist/commands/hooks.js +30 -0
- package/dist/commands/hunt-pro.js +1 -0
- package/dist/commands/hunt.js +86 -0
- package/dist/commands/map.js +33 -0
- package/dist/commands/mcp.js +17 -0
- package/dist/commands/pack.js +48 -0
- package/dist/commands/prove-pro.js +1 -0
- package/dist/commands/prove.js +173 -0
- package/dist/commands/report-gate.js +92 -0
- package/dist/commands/scan.js +158 -0
- package/dist/confirm/agreement.js +18 -0
- package/dist/confirm/chain.js +84 -0
- package/dist/confirm/confirm.js +112 -0
- package/dist/confirm/honesty.js +75 -0
- package/dist/confirm/lead.js +92 -0
- package/dist/confirm/merge.js +88 -0
- package/dist/confirm/query.js +145 -0
- package/dist/confirm/rule.js +86 -0
- package/dist/confirm/store.js +42 -0
- package/dist/confirm/summary.js +6 -0
- package/dist/confirm/synthesis.js +28 -0
- package/dist/discover/detectors.js +50 -0
- package/dist/discover/discover.js +270 -0
- package/dist/discover/extract.js +282 -0
- package/dist/discover/git.js +40 -0
- package/dist/discover/kit-files.js +47 -0
- package/dist/discover/walk.js +110 -0
- package/dist/env.js +57 -0
- package/dist/gate/gate.js +173 -0
- package/dist/hooks/install.js +153 -0
- package/dist/hooks/run.js +112 -0
- package/dist/map/analyze.js +201 -0
- package/dist/map/build.js +253 -0
- package/dist/map/candidates.js +49 -0
- package/dist/map/cblocks.js +77 -0
- package/dist/map/config.js +291 -0
- package/dist/map/dotnet.js +242 -0
- package/dist/map/entry.js +78 -0
- package/dist/map/expo.js +190 -0
- package/dist/map/go.js +292 -0
- package/dist/map/js-express.js +143 -0
- package/dist/map/js-misc.js +295 -0
- package/dist/map/js-next.js +292 -0
- package/dist/map/laravel.js +389 -0
- package/dist/map/lex-more.js +305 -0
- package/dist/map/lex.js +425 -0
- package/dist/map/light-index.js +191 -0
- package/dist/map/light-profiles.js +324 -0
- package/dist/map/light.js +574 -0
- package/dist/map/local-vocab.js +27 -0
- package/dist/map/mobile.js +326 -0
- package/dist/map/py-django.js +281 -0
- package/dist/map/py.js +391 -0
- package/dist/map/rails.js +391 -0
- package/dist/map/repo.js +178 -0
- package/dist/map/rust.js +312 -0
- package/dist/map/spring.js +207 -0
- package/dist/map/taint.js +96 -0
- package/dist/map/touches.js +355 -0
- package/dist/map/vocab.js +88 -0
- package/dist/mapping/controls.js +34 -0
- package/dist/mapping/dependency-policy.js +44 -0
- package/dist/mapping/map.js +44 -0
- package/dist/mapping/maps/gitleaks.json +12 -0
- package/dist/mapping/maps/osv-scanner.json +7 -0
- package/dist/mapping/maps/trivy.json +13 -0
- package/dist/mcp/confine.js +59 -0
- package/dist/mcp/protocol.js +140 -0
- package/dist/mcp/schema.js +43 -0
- package/dist/mcp/server.js +37 -0
- package/dist/mcp/tools.js +238 -0
- package/dist/packs/coverage.js +79 -0
- package/dist/packs/manifest.js +36 -0
- package/dist/packs/validate.js +271 -0
- package/dist/prove/container.js +276 -0
- package/dist/prove/exec-main.js +20 -0
- package/dist/prove/executor.js +149 -0
- package/dist/prove/local.js +131 -0
- package/dist/prove/probes.js +65 -0
- package/dist/prove/recipe.js +278 -0
- package/dist/prove/run.js +136 -0
- package/dist/prove/runner.js +1 -0
- package/dist/prove/safety.js +126 -0
- package/dist/prove/spec.js +357 -0
- package/dist/prove/store.js +65 -0
- package/dist/report/build.js +297 -0
- package/dist/report/entry-points.js +75 -0
- package/dist/report/hunt-section.js +71 -0
- package/dist/report/kit.js +41 -0
- package/dist/report/load.js +35 -0
- package/dist/report/messages.js +273 -0
- package/dist/report/prove-section.js +65 -0
- package/dist/report/report.js +156 -0
- package/dist/report/surface.js +21 -0
- package/dist/report/test-code.js +51 -0
- package/dist/report/tiers.js +44 -0
- package/dist/scanners/common.js +162 -0
- package/dist/scanners/gitleaks.js +90 -0
- package/dist/scanners/index.js +113 -0
- package/dist/scanners/osv-scanner.js +77 -0
- package/dist/scanners/run-targets.js +34 -0
- package/dist/scanners/secret-noise.js +89 -0
- package/dist/scanners/semgrep.js +142 -0
- package/dist/scanners/trivy.js +65 -0
- package/dist/scanners/types.js +1 -0
- package/dist/types/check.js +1 -0
- package/dist/types/codemap.js +1 -0
- package/dist/types/findings.js +5 -0
- package/dist/types/hunt.js +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/prove.js +1 -0
- package/dist/types/results.js +2 -0
- package/dist/types/scan.js +1 -0
- package/dist/types/stack.js +5 -0
- package/dist/util.js +71 -0
- package/dist/version.js +2 -0
- package/package.json +46 -4
- package/packs/android/fixture-result.json +43 -0
- package/packs/android/pack.json +56 -0
- package/packs/dotnet/fixture-result.json +62 -0
- package/packs/dotnet/pack.json +88 -0
- package/packs/expo/answer-key.json +51 -0
- package/packs/expo/fixture-result.json +62 -0
- package/packs/expo/pack.json +79 -0
- package/packs/firebase/answer-key.json +27 -0
- package/packs/firebase/fixture-result.json +43 -0
- package/packs/firebase/pack.json +58 -0
- package/packs/go/answer-key.json +101 -0
- package/packs/go/fixture-result.json +64 -0
- package/packs/go/pack.json +99 -0
- package/packs/ios/fixture-result.json +43 -0
- package/packs/ios/pack.json +55 -0
- package/packs/laravel/answer-key.json +84 -0
- package/packs/laravel/fixture-result.json +60 -0
- package/packs/laravel/pack.json +91 -0
- package/packs/node-ts/answer-key.json +220 -0
- package/packs/node-ts/fixture-result.json +114 -0
- package/packs/node-ts/pack.json +134 -0
- package/packs/python/answer-key.json +195 -0
- package/packs/python/fixture-result.json +119 -0
- package/packs/python/pack.json +121 -0
- package/packs/rails/answer-key.json +84 -0
- package/packs/rails/fixture-result.json +60 -0
- package/packs/rails/pack.json +78 -0
- package/packs/rust/fixture-result.json +43 -0
- package/packs/rust/pack.json +69 -0
- package/packs/spring/fixture-result.json +63 -0
- package/packs/spring/pack.json +103 -0
- package/packs/supabase/answer-key.json +52 -0
- package/packs/supabase/fixture-result.json +58 -0
- package/packs/supabase/pack.json +60 -0
- package/rules/gitleaks.toml +256 -0
- package/rules/semgrep/api-route-no-auth.yaml +31 -0
- package/rules/semgrep/cors-reflect-origin.yaml +48 -0
- package/rules/semgrep/eval-or-new-function.yaml +185 -0
- package/rules/semgrep/log-session-token.yaml +48 -0
- package/rules/semgrep/math-random-tokens.yaml +24 -0
- package/rules/semgrep/next-public-secret-name.yaml +32 -0
- package/rules/semgrep/postgrest-filter-injection.yaml +78 -0
- package/rules/semgrep/raw-sql-concat.yaml +42 -0
- package/rules/semgrep/read-by-id-no-owner.yaml +49 -0
- package/rules/semgrep/server-action-no-auth.yaml +26 -0
- package/rules/semgrep/sql-variants.yaml +99 -0
- package/rules/semgrep/ssrf-unguarded-url.yaml +137 -0
- package/rules/semgrep/subprocess-injection.yaml +163 -0
- package/rules/semgrep/taint-go.yaml +333 -0
- package/rules/semgrep/taint-java.yaml +317 -0
- package/rules/semgrep/taint-javascript.yaml +412 -0
- package/rules/semgrep/taint-php.yaml +361 -0
- package/rules/semgrep/taint-python.yaml +414 -0
- package/rules/semgrep/tool-endpoint-admin-client.yaml +86 -0
- package/rules/semgrep/user-metadata-authz.yaml +22 -0
- package/rules/semgrep/verify-result-unused.yaml +62 -0
- package/rules/semgrep/webhook-parse-before-verify.yaml +29 -0
- package/rules/test-artifact-ids.json +4 -0
- package/rules/tiers.json +427 -0
- package/schemas/check-results.schema.json +55 -0
- package/schemas/check.schema.json +41 -0
- package/schemas/codemap.schema.json +216 -0
- package/schemas/findings.schema.json +1048 -0
- package/schemas/hunt-output.schema.json +46 -0
- package/schemas/hunt-result.schema.json +58 -0
- package/schemas/results.schema.json +17 -0
- package/schemas/scan-hits.schema.json +32 -0
- package/schemas/scan-meta.schema.json +25 -0
- package/schemas/stack.schema.json +93 -0
- package/src/checks/README.md +32 -0
- package/src/checks/code/admin-role.ts +49 -0
- package/src/checks/code/owner-filter.ts +95 -0
- package/src/checks/code/shared.ts +66 -0
- package/src/checks/code/webhook-verify.ts +75 -0
- package/src/checks/context.ts +72 -0
- package/src/checks/map/config-checks.ts +79 -0
- package/src/checks/map/entry-checks.ts +116 -0
- package/src/checks/map/shared.ts +63 -0
- package/src/checks/ops.ts +280 -0
- package/src/checks/pro-map.ts +4 -0
- package/src/checks/run.ts +80 -0
- package/src/checks/types.ts +12 -0
- package/src/ci/comment.ts +52 -0
- package/src/ci/sarif-check.ts +100 -0
- package/src/ci/sarif.ts +72 -0
- package/src/cli.ts +273 -0
- package/src/commands/ci.ts +104 -0
- package/src/commands/git.ts +59 -0
- package/src/commands/hooks.ts +27 -0
- package/src/commands/hunt-pro.ts +4 -0
- package/src/commands/hunt.ts +68 -0
- package/src/commands/map.ts +27 -0
- package/src/commands/mcp.ts +18 -0
- package/src/commands/pack.ts +34 -0
- package/src/commands/prove-pro.ts +4 -0
- package/src/commands/prove.ts +126 -0
- package/src/commands/report-gate.ts +75 -0
- package/src/commands/scan.ts +156 -0
- package/src/confirm/agreement.ts +23 -0
- package/src/confirm/chain.ts +67 -0
- package/src/confirm/confirm.ts +107 -0
- package/src/confirm/honesty.ts +65 -0
- package/src/confirm/lead.ts +76 -0
- package/src/confirm/merge.ts +89 -0
- package/src/confirm/query.ts +126 -0
- package/src/confirm/rule.ts +76 -0
- package/src/confirm/store.ts +42 -0
- package/src/confirm/summary.ts +9 -0
- package/src/confirm/synthesis.ts +33 -0
- package/src/discover/detectors.ts +71 -0
- package/src/discover/discover.ts +253 -0
- package/src/discover/extract.ts +235 -0
- package/src/discover/git.ts +31 -0
- package/src/discover/kit-files.ts +37 -0
- package/src/discover/walk.ts +88 -0
- package/src/env.ts +51 -0
- package/src/gate/gate.ts +169 -0
- package/src/hooks/install.ts +142 -0
- package/src/hooks/run.ts +121 -0
- package/src/map/analyze.ts +219 -0
- package/src/map/build.ts +206 -0
- package/src/map/candidates.ts +47 -0
- package/src/map/cblocks.ts +55 -0
- package/src/map/config.ts +207 -0
- package/src/map/dotnet.ts +205 -0
- package/src/map/entry.ts +80 -0
- package/src/map/expo.ts +171 -0
- package/src/map/go.ts +271 -0
- package/src/map/js-express.ts +135 -0
- package/src/map/js-misc.ts +221 -0
- package/src/map/js-next.ts +282 -0
- package/src/map/laravel.ts +327 -0
- package/src/map/lex-more.ts +152 -0
- package/src/map/lex.ts +274 -0
- package/src/map/light-index.ts +134 -0
- package/src/map/light-profiles.ts +363 -0
- package/src/map/light.ts +484 -0
- package/src/map/local-vocab.ts +30 -0
- package/src/map/mobile.ts +263 -0
- package/src/map/py-django.ts +239 -0
- package/src/map/py.ts +329 -0
- package/src/map/rails.ts +291 -0
- package/src/map/repo.ts +150 -0
- package/src/map/rust.ts +256 -0
- package/src/map/spring.ts +185 -0
- package/src/map/taint.ts +86 -0
- package/src/map/touches.ts +296 -0
- package/src/map/vocab.ts +106 -0
- package/src/mapping/controls.ts +47 -0
- package/src/mapping/dependency-policy.ts +55 -0
- package/src/mapping/map.ts +59 -0
- package/src/mapping/maps/gitleaks.json +12 -0
- package/src/mapping/maps/osv-scanner.json +7 -0
- package/src/mapping/maps/trivy.json +13 -0
- package/src/mcp/confine.ts +44 -0
- package/src/mcp/protocol.ts +144 -0
- package/src/mcp/schema.ts +35 -0
- package/src/mcp/server.ts +32 -0
- package/src/mcp/tools.ts +235 -0
- package/src/packs/coverage.ts +98 -0
- package/src/packs/manifest.ts +97 -0
- package/src/packs/validate.ts +204 -0
- package/src/prove/container.ts +237 -0
- package/src/prove/exec-main.ts +16 -0
- package/src/prove/executor.ts +130 -0
- package/src/prove/local.ts +100 -0
- package/src/prove/probes.ts +68 -0
- package/src/prove/recipe.ts +215 -0
- package/src/prove/run.ts +124 -0
- package/src/prove/runner.ts +28 -0
- package/src/prove/safety.ts +98 -0
- package/src/prove/spec.ts +256 -0
- package/src/prove/store.ts +48 -0
- package/src/report/build.ts +304 -0
- package/src/report/entry-points.ts +78 -0
- package/src/report/hunt-section.ts +67 -0
- package/src/report/kit.ts +59 -0
- package/src/report/load.ts +49 -0
- package/src/report/messages.ts +270 -0
- package/src/report/prove-section.ts +61 -0
- package/src/report/report.ts +160 -0
- package/src/report/surface.ts +32 -0
- package/src/report/test-code.ts +56 -0
- package/src/report/tiers.ts +50 -0
- package/src/scanners/common.ts +149 -0
- package/src/scanners/gitleaks.ts +89 -0
- package/src/scanners/index.ts +103 -0
- package/src/scanners/osv-scanner.ts +90 -0
- package/src/scanners/run-targets.ts +35 -0
- package/src/scanners/secret-noise.ts +80 -0
- package/src/scanners/semgrep.ts +139 -0
- package/src/scanners/trivy.ts +66 -0
- package/src/scanners/types.ts +98 -0
- package/src/types/check.ts +52 -0
- package/src/types/codemap.ts +142 -0
- package/src/types/findings.ts +107 -0
- package/src/types/hunt.ts +97 -0
- package/src/types/index.ts +10 -0
- package/src/types/prove.ts +83 -0
- package/src/types/results.ts +15 -0
- package/src/types/scan.ts +63 -0
- package/src/types/stack.ts +61 -0
- package/src/util.ts +59 -0
- package/src/version.ts +2 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// A small declarative query language over codemap.json, and its runner. A model proposes a query; the engine runs it. It has
|
|
2
|
+
// no regular expressions and no code, so a query cannot loop or run anything. See docs/INTERFACES.md, section 13.
|
|
3
|
+
import type { CodeMap, Cond, EntryPoint, Json, LeadStep, Query } from '../types/index.ts';
|
|
4
|
+
|
|
5
|
+
export const MAX_DEPTH = 8;
|
|
6
|
+
export const MAX_NODES = 60;
|
|
7
|
+
export const WITNESS_SLACK = 2; // a fact counts when its line is within this many lines of a cited line
|
|
8
|
+
export const TOO_BROAD_MIN = 3; // a query that matches every entry point of a map this large proves nothing
|
|
9
|
+
const OPS = new Set(['eq', 'ne', 'in', 'contains', 'startsWith', 'exists', 'gt', 'lt']);
|
|
10
|
+
const FIELD = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
11
|
+
const BAD_SEG = new Set(['__proto__', 'constructor', 'prototype']);
|
|
12
|
+
const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
13
|
+
const goodField = (f: unknown): f is string => typeof f === 'string' && FIELD.test(f) && !f.split('.').some((s) => BAD_SEG.has(s));
|
|
14
|
+
|
|
15
|
+
export function parseQuery(v: unknown): { ok: true; query: Query } | { ok: false; reason: string } {
|
|
16
|
+
if (!isObj(v) || !('where' in v)) return { ok: false, reason: 'a query is {"where": condition}' };
|
|
17
|
+
let nodes = 0;
|
|
18
|
+
const cond = (c: unknown, depth: number): Cond | string => {
|
|
19
|
+
if (depth > MAX_DEPTH) return `nested deeper than ${MAX_DEPTH}`;
|
|
20
|
+
if (++nodes > MAX_NODES) return `more than ${MAX_NODES} conditions`;
|
|
21
|
+
if (!isObj(c)) return 'a condition must be an object';
|
|
22
|
+
for (const k of ['all', 'any'] as const) {
|
|
23
|
+
if (k in c) {
|
|
24
|
+
if (!Array.isArray(c[k]) || c[k].length === 0) return `${k} needs a non empty list`;
|
|
25
|
+
const out: Cond[] = [];
|
|
26
|
+
for (const x of c[k] as unknown[]) { const r = cond(x, depth + 1); if (typeof r === 'string') return r; out.push(r); }
|
|
27
|
+
return k === 'all' ? { all: out } : { any: out };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if ('not' in c) { const r = cond(c.not, depth + 1); return typeof r === 'string' ? r : { not: r }; }
|
|
31
|
+
for (const k of ['some', 'none'] as const) {
|
|
32
|
+
if (k in c) {
|
|
33
|
+
if (!goodField(c[k])) return `${k} needs a field name`;
|
|
34
|
+
let where: Cond | undefined;
|
|
35
|
+
if (c.where !== undefined) { const r = cond(c.where, depth + 1); if (typeof r === 'string') return r; where = r; }
|
|
36
|
+
return k === 'some' ? { some: c.some as string, ...(where ? { where } : {}) } : { none: c.none as string, ...(where ? { where } : {}) };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if ('field' in c) {
|
|
40
|
+
if (!goodField(c.field)) return 'field is not a plain dotted name';
|
|
41
|
+
if (typeof c.op !== 'string' || !OPS.has(c.op)) return `op is one of ${[...OPS].join(', ')}`;
|
|
42
|
+
const val = c.value as Json | undefined;
|
|
43
|
+
const scalar = (x: unknown): boolean => x === null || ['string', 'number', 'boolean'].includes(typeof x);
|
|
44
|
+
if (c.op === 'in' ? !Array.isArray(val) || !val.every(scalar) || val.length > 20 : c.op === 'exists' ? val !== undefined && typeof val !== 'boolean' : !scalar(val)) return `value does not fit op ${c.op}`;
|
|
45
|
+
if ((c.op === 'gt' || c.op === 'lt') && typeof val !== 'number') return `${c.op} needs a number`;
|
|
46
|
+
if ((c.op === 'startsWith') && typeof val !== 'string') return 'startsWith needs a string';
|
|
47
|
+
return { field: c.field, op: c.op as 'eq', ...(val !== undefined ? { value: val } : {}) };
|
|
48
|
+
}
|
|
49
|
+
return 'a condition is all, any, not, some, none or field';
|
|
50
|
+
};
|
|
51
|
+
const r = cond(v.where, 1);
|
|
52
|
+
return typeof r === 'string' ? { ok: false, reason: r } : { ok: true, query: { where: r } };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface Witness { file: string; line: number }
|
|
56
|
+
interface Ev { ok: boolean; w: Witness[] }
|
|
57
|
+
const NO: Ev = { ok: false, w: [] };
|
|
58
|
+
|
|
59
|
+
function pick(node: unknown, path: string): unknown {
|
|
60
|
+
let cur = node;
|
|
61
|
+
for (const seg of path.split('.')) {
|
|
62
|
+
if (!isObj(cur) || !Object.hasOwn(cur, seg)) return undefined;
|
|
63
|
+
cur = cur[seg];
|
|
64
|
+
}
|
|
65
|
+
return cur;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function compare(c: Extract<Cond, { field: string }>, node: unknown): boolean {
|
|
69
|
+
const got = pick(node, c.field);
|
|
70
|
+
const want = c.value;
|
|
71
|
+
switch (c.op) {
|
|
72
|
+
case 'exists': return (got !== undefined && got !== null) === (want !== false);
|
|
73
|
+
case 'eq': return got === want;
|
|
74
|
+
case 'ne': return got !== want;
|
|
75
|
+
case 'in': return Array.isArray(want) && (want as unknown[]).includes(got);
|
|
76
|
+
case 'contains': return Array.isArray(got) ? got.includes(want) : typeof got === 'string' && typeof want === 'string' && got.includes(want);
|
|
77
|
+
case 'startsWith': return typeof got === 'string' && typeof want === 'string' && got.startsWith(want);
|
|
78
|
+
case 'gt': return typeof got === 'number' && typeof want === 'number' && got > want;
|
|
79
|
+
case 'lt': return typeof got === 'number' && typeof want === 'number' && got < want;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function run(c: Cond, node: unknown, file: string): Ev {
|
|
84
|
+
if ('all' in c) {
|
|
85
|
+
const rs = c.all.map((x) => run(x, node, file));
|
|
86
|
+
return rs.every((r) => r.ok) ? { ok: true, w: rs.flatMap((r) => r.w) } : NO;
|
|
87
|
+
}
|
|
88
|
+
if ('any' in c) {
|
|
89
|
+
const rs = c.any.map((x) => run(x, node, file)).filter((r) => r.ok);
|
|
90
|
+
return rs.length ? { ok: true, w: rs.flatMap((r) => r.w) } : NO;
|
|
91
|
+
}
|
|
92
|
+
if ('not' in c) return run(c.not, node, file).ok ? NO : { ok: true, w: [] };
|
|
93
|
+
if ('some' in c || 'none' in c) {
|
|
94
|
+
const f = 'some' in c ? c.some : c.none;
|
|
95
|
+
const arr = pick(node, f);
|
|
96
|
+
const items = Array.isArray(arr) ? arr : [];
|
|
97
|
+
const hits = items.filter((it) => !c.where || run(c.where, it, isObj(it) && typeof it.file === 'string' ? it.file : file).ok);
|
|
98
|
+
if ('none' in c) return hits.length === 0 ? { ok: true, w: [] } : NO;
|
|
99
|
+
if (!hits.length) return NO;
|
|
100
|
+
const w: Witness[] = [];
|
|
101
|
+
for (const it of hits) if (isObj(it) && typeof it.line === 'number') w.push({ file: typeof it.file === 'string' ? it.file : file, line: it.line });
|
|
102
|
+
return { ok: true, w };
|
|
103
|
+
}
|
|
104
|
+
return compare(c, node) ? { ok: true, w: [] } : NO;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface QueryOutcome { confirmed: boolean; detail: string; witnesses: Witness[] }
|
|
108
|
+
|
|
109
|
+
// Runs the query on the lead's entry point. The lead is confirmed only when the entry point matches and at least one fact the
|
|
110
|
+
// query matched (a data touch, a protection, a read or an outbound call) sits on or beside a line the lead cites. A query that
|
|
111
|
+
// matches every entry point of a map of TOO_BROAD_MIN or more proves nothing and is not accepted.
|
|
112
|
+
export function runQuery(map: CodeMap, q: Query, entryId: string, cited: LeadStep[]): QueryOutcome {
|
|
113
|
+
const ep = map.entry_points.find((e) => e.id === entryId);
|
|
114
|
+
if (!ep) return { confirmed: false, detail: 'the entry point is not in the code map', witnesses: [] };
|
|
115
|
+
const at = (e: EntryPoint): Ev => run(q.where, e, e.handler?.file ?? e.file);
|
|
116
|
+
const mine = at(ep);
|
|
117
|
+
if (!mine.ok) return { confirmed: false, detail: 'the query does not match this entry point', witnesses: [] };
|
|
118
|
+
const matched = map.entry_points.filter((e) => at(e).ok).length;
|
|
119
|
+
if (map.entry_points.length >= TOO_BROAD_MIN && matched === map.entry_points.length) {
|
|
120
|
+
return { confirmed: false, detail: `the query matches all ${matched} entry points, so it proves nothing about this one`, witnesses: [] };
|
|
121
|
+
}
|
|
122
|
+
const near = mine.w.filter((w) => cited.some((s) => s.file === w.file && Math.abs(s.line - w.line) <= WITNESS_SLACK));
|
|
123
|
+
if (!near.length) return { confirmed: false, detail: 'no fact the query matched sits on a line the lead cites', witnesses: [] };
|
|
124
|
+
const uniq = [...new Map(near.map((w) => [`${w.file}:${w.line}`, w])).values()].sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
|
|
125
|
+
return { confirmed: true, detail: `the query matches this entry point and ${matched - 1} other of ${map.entry_points.length}; matched facts at ${uniq.map((w) => `${w.file}:${w.line}`).join(', ')}`, witnesses: uniq };
|
|
126
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Confirmation by a generated Semgrep rule. The rule must match the lines the lead cites in the real file, and it must not match a
|
|
2
|
+
// negative control: a safe sample of the same kind of code that the model supplies. Both runs must really scan their file, so a rule
|
|
3
|
+
// for another language cannot pass by matching nothing. Semgrep runs offline with metrics off. If Semgrep is not installed the
|
|
4
|
+
// outcome is "not run" and the lead stays a lead.
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join, resolve } from 'node:path';
|
|
10
|
+
import type { Lead } from '../types/index.ts';
|
|
11
|
+
|
|
12
|
+
export interface SemgrepRun { status: number | null; stdout: string; missing?: boolean }
|
|
13
|
+
export type SemgrepRunner = (args: string[]) => SemgrepRun;
|
|
14
|
+
const TIMEOUT_MS = 120_000;
|
|
15
|
+
const SLACK = 1;
|
|
16
|
+
|
|
17
|
+
export const spawnSemgrep: SemgrepRunner = (args) => {
|
|
18
|
+
const r = spawnSync('semgrep', args, { encoding: 'utf8', timeout: TIMEOUT_MS, maxBuffer: 32 * 1024 * 1024, env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '', SEMGREP_SEND_METRICS: 'off', SEMGREP_ENABLE_VERSION_CHECK: '0' } });
|
|
19
|
+
if ((r.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') return { status: null, stdout: '', missing: true };
|
|
20
|
+
return { status: r.status, stdout: r.stdout ?? '' };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type RuleOutcome =
|
|
24
|
+
| { status: 'confirmed'; detail: string; rule_id: string; tool_version: string; config_sha256: string }
|
|
25
|
+
| { status: 'not_confirmed'; detail: string }
|
|
26
|
+
| { status: 'not_run'; detail: string };
|
|
27
|
+
|
|
28
|
+
interface Out { results: { start: { line: number }; end: { line: number } }[]; errors: unknown[]; paths?: { scanned?: string[] } }
|
|
29
|
+
const BANNED = /pattern-where-python|pattern-sources:\s*\n\s*-\s*exact|\binclude:|r2c-internal/;
|
|
30
|
+
|
|
31
|
+
function scanFile(run: SemgrepRunner, rule: string, target: string): { out: Out } | { fail: string } | { missing: true } {
|
|
32
|
+
const r = run(['scan', '--config', rule, '--json', '--quiet', '--metrics=off', '--disable-version-check', '--no-git-ignore', '--timeout', '10', target]);
|
|
33
|
+
if (r.missing) return { missing: true };
|
|
34
|
+
let out: Out;
|
|
35
|
+
try { out = JSON.parse(r.stdout) as Out; } catch { return { fail: 'semgrep printed no JSON for this rule' }; }
|
|
36
|
+
if (!Array.isArray(out.results)) return { fail: 'semgrep printed no results list' };
|
|
37
|
+
if (Array.isArray(out.errors) && out.errors.length) return { fail: 'semgrep reported an error for the rule or the file' };
|
|
38
|
+
if (!(out.paths?.scanned ?? []).some((p) => resolve(p) === resolve(target))) return { fail: 'semgrep did not scan the file (the rule does not cover its language)' };
|
|
39
|
+
return { out };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function confirmRule(lead: Lead, repo: string, run: SemgrepRunner = spawnSemgrep): RuleOutcome {
|
|
43
|
+
const p = lead.evidence_proposal;
|
|
44
|
+
if (p.kind !== 'semgrep_rule') return { status: 'not_confirmed', detail: 'no rule was proposed' };
|
|
45
|
+
const ids = [...p.rule_yaml.matchAll(/^\s*-\s*id:\s*([^\s#]+)/gm)].map((m) => m[1]);
|
|
46
|
+
if (!/^\s*rules:/m.test(p.rule_yaml) || ids.length !== 1) return { status: 'not_confirmed', detail: 'the rule file must hold exactly one rule' };
|
|
47
|
+
if (!/^[A-Za-z0-9._-]{1,100}$/.test(ids[0])) return { status: 'not_confirmed', detail: 'the rule id has characters outside letters, digits, dot, underscore and hyphen' };
|
|
48
|
+
if (BANNED.test(p.rule_yaml)) return { status: 'not_confirmed', detail: 'the rule uses a feature the engine does not accept' };
|
|
49
|
+
const norm = (s: string): string => s.replace(/\s+/g, ' ').trim();
|
|
50
|
+
if (norm(p.control_code).includes(norm(lead.quoted_code))) return { status: 'not_confirmed', detail: 'the negative control still holds the quoted code, so it is not a control' };
|
|
51
|
+
|
|
52
|
+
const sink = lead.path[lead.path.length - 1];
|
|
53
|
+
const dir = mkdtempSync(join(tmpdir(), 'hp-rule-'));
|
|
54
|
+
try {
|
|
55
|
+
const ruleFile = join(dir, 'rule.yaml');
|
|
56
|
+
const controlFile = join(dir, `control.${p.control_extension}`);
|
|
57
|
+
writeFileSync(ruleFile, p.rule_yaml);
|
|
58
|
+
writeFileSync(controlFile, p.control_code);
|
|
59
|
+
const ver = run(['--version']);
|
|
60
|
+
if (ver.missing) return { status: 'not_run', detail: 'Semgrep is not installed' };
|
|
61
|
+
const hit = scanFile(run, ruleFile, join(resolve(repo), sink.file));
|
|
62
|
+
if ('missing' in hit) return { status: 'not_run', detail: 'Semgrep is not installed' };
|
|
63
|
+
if ('fail' in hit) return { status: 'not_confirmed', detail: hit.fail };
|
|
64
|
+
const onSink = hit.out.results.some((r) => r.end.line >= sink.line - SLACK && r.start.line <= sink.line + SLACK);
|
|
65
|
+
if (!onSink) return { status: 'not_confirmed', detail: `the rule does not match the cited lines (${sink.file}:${sink.line})` };
|
|
66
|
+
const ctl = scanFile(run, ruleFile, controlFile);
|
|
67
|
+
if ('missing' in ctl) return { status: 'not_run', detail: 'Semgrep is not installed' };
|
|
68
|
+
if ('fail' in ctl) return { status: 'not_confirmed', detail: `negative control: ${ctl.fail}` };
|
|
69
|
+
if (ctl.out.results.length) return { status: 'not_confirmed', detail: 'the rule also matches the negative control, so it does not tell the flaw from safe code' };
|
|
70
|
+
return {
|
|
71
|
+
status: 'confirmed', detail: `the rule matches ${sink.file}:${sink.line} and does not match the negative control`, rule_id: `hunt.${ids[0]}`.slice(0, 190),
|
|
72
|
+
tool_version: (/^\d+\.\d+[\w.+-]*/m.exec(ver.stdout)?.[0] ?? 'unknown').replace(/[^A-Za-z0-9._+-]/g, '-').slice(0, 59) || 'unknown',
|
|
73
|
+
config_sha256: createHash('sha256').update(p.rule_yaml).digest('hex'),
|
|
74
|
+
};
|
|
75
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
76
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Where hunt files live and how they are read and written: <out>/<commit>/hunt/. Every answer is written the moment it arrives,
|
|
2
|
+
// through a temporary file and a rename, so an interrupted run leaves whole files only and can resume.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import type { HuntAnswer, HuntPlan, HuntResult, HuntSynthesis } from '../types/index.ts';
|
|
7
|
+
import { cmp, stableJson } from '../util.ts';
|
|
8
|
+
|
|
9
|
+
export const huntDir = (scanDir: string): string => join(scanDir, 'hunt');
|
|
10
|
+
const slug = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'entry';
|
|
11
|
+
export const answerFile = (hunt: string, run: number, entryId: string): string =>
|
|
12
|
+
join(hunt, `run-${run}`, `${slug(entryId)}-${createHash('sha256').update(entryId).digest('hex').slice(0, 8)}.json`);
|
|
13
|
+
|
|
14
|
+
function writeAtomic(path: string, text: string): void {
|
|
15
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
16
|
+
const tmp = `${path}.tmp`;
|
|
17
|
+
writeFileSync(tmp, text);
|
|
18
|
+
renameSync(tmp, path);
|
|
19
|
+
}
|
|
20
|
+
const readJson = <T>(p: string): T => { try { return JSON.parse(readFileSync(p, 'utf8')) as T; } catch (e) { throw new Error(`${p} cannot be read: ${(e as Error).message}`); } };
|
|
21
|
+
|
|
22
|
+
export const writeAnswer = (hunt: string, a: HuntAnswer): void => writeAtomic(answerFile(hunt, a.run, a.entry_id), stableJson(a));
|
|
23
|
+
export const hasAnswer = (hunt: string, run: number, entryId: string): boolean => existsSync(answerFile(hunt, run, entryId));
|
|
24
|
+
|
|
25
|
+
export function readAnswers(hunt: string): HuntAnswer[] {
|
|
26
|
+
if (!existsSync(hunt)) return [];
|
|
27
|
+
const out: HuntAnswer[] = [];
|
|
28
|
+
for (const d of readdirSync(hunt).filter((n) => /^run-\d+$/.test(n)).sort(cmp)) {
|
|
29
|
+
for (const f of readdirSync(join(hunt, d)).filter((n) => n.endsWith('.json')).sort(cmp)) out.push(readJson<HuntAnswer>(join(hunt, d, f)));
|
|
30
|
+
}
|
|
31
|
+
return out.sort((a, b) => a.run - b.run || cmp(a.entry_id, b.entry_id));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const writePlan = (hunt: string, p: HuntPlan): void => writeAtomic(join(hunt, 'plan.json'), stableJson(p));
|
|
35
|
+
export const readPlan = (hunt: string): HuntPlan | null => (existsSync(join(hunt, 'plan.json')) ? readJson<HuntPlan>(join(hunt, 'plan.json')) : null);
|
|
36
|
+
export const writeResult = (hunt: string, r: HuntResult): void => writeAtomic(join(hunt, 'result.json'), stableJson(r));
|
|
37
|
+
export const readResult = (hunt: string): HuntResult | null => (existsSync(join(hunt, 'result.json')) ? readJson<HuntResult>(join(hunt, 'result.json')) : null);
|
|
38
|
+
export const writeSynthesis = (hunt: string, s: HuntSynthesis): void => writeAtomic(join(hunt, 'synthesis.json'), stableJson(s));
|
|
39
|
+
export const readSynthesis = (hunt: string): HuntSynthesis | null => (existsSync(join(hunt, 'synthesis.json')) ? readJson<HuntSynthesis>(join(hunt, 'synthesis.json')) : null);
|
|
40
|
+
export const writeSkipped = (hunt: string, reason: string): void => writeAtomic(join(hunt, 'skipped.json'), stableJson({ schema_version: '1.0.0', reason }));
|
|
41
|
+
export const readSkipped = (hunt: string): string | null => (existsSync(join(hunt, 'skipped.json')) ? readJson<{ reason: string }>(join(hunt, 'skipped.json')).reason : null);
|
|
42
|
+
export const hasHuntFiles = (hunt: string): boolean => existsSync(hunt) && readdirSync(hunt).some((n) => /^run-\d+$/.test(n) || n === 'plan.json');
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// The two lines `hullproof hunt` prints about a result.
|
|
2
|
+
import type { HuntResult } from '../types/index.ts';
|
|
3
|
+
|
|
4
|
+
export function summary(r: HuntResult): string[] {
|
|
5
|
+
return [
|
|
6
|
+
`hullproof hunt: ${r.confirmed.length} confirmed, ${r.leads.length} leads not confirmed, ${r.hallucinations.count} hallucinations dropped, ${r.malformed.count} malformed`,
|
|
7
|
+
`hullproof hunt: calls ${r.calls.complete} complete, ${r.calls.incomplete} incomplete, ${r.calls.missing} not saved; tokens ${r.usage.input_tokens} in, ${r.usage.output_tokens} out (reported, not limited)`,
|
|
8
|
+
];
|
|
9
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The guard around the synthesis step. The synthesis call may explain the findings and leads that are already in result.json. It
|
|
2
|
+
// cannot add one, remove one or change one: this function keeps only a summary and one explanation per known key, and nothing
|
|
3
|
+
// else from the model's reply reaches any file the gate reads.
|
|
4
|
+
import type { HuntResult, HuntSynthesis } from '../types/index.ts';
|
|
5
|
+
import { cmp } from '../util.ts';
|
|
6
|
+
import { plain } from './lead.ts';
|
|
7
|
+
|
|
8
|
+
export const SYNTHESIS_LIMITS = { summary: 4000, explanation: 800, explanations: 60 } as const;
|
|
9
|
+
|
|
10
|
+
export function guardSynthesis(result: HuntResult, reply: unknown, meta: { provider: string; model: string; usage: { input_tokens: number; output_tokens: number } }): HuntSynthesis {
|
|
11
|
+
const known = new Set([...result.confirmed, ...result.leads].map((l) => l.key));
|
|
12
|
+
const o = reply && typeof reply === 'object' && !Array.isArray(reply) ? (reply as Record<string, unknown>) : {};
|
|
13
|
+
const summary = typeof o.summary === 'string' ? plain(o.summary, SYNTHESIS_LIMITS.summary) : '';
|
|
14
|
+
const seen = new Set<string>();
|
|
15
|
+
const explanations: HuntSynthesis['explanations'] = [];
|
|
16
|
+
if (Array.isArray(o.explanations)) {
|
|
17
|
+
for (const x of o.explanations) {
|
|
18
|
+
if (!x || typeof x !== 'object') continue;
|
|
19
|
+
const { key, text } = x as { key?: unknown; text?: unknown };
|
|
20
|
+
if (typeof key !== 'string' || typeof text !== 'string' || !known.has(key) || seen.has(key) || explanations.length >= SYNTHESIS_LIMITS.explanations) continue;
|
|
21
|
+
seen.add(key);
|
|
22
|
+
explanations.push({ key, text: plain(text, SYNTHESIS_LIMITS.explanation) });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
explanations.sort((a, b) => cmp(a.key, b.key));
|
|
26
|
+
return { schema_version: '1.0.0', status: 'complete', stop: null, detail: null, provider: meta.provider, model: meta.model, summary, explanations, usage: meta.usage };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// A saved synthesis is applied again on replay against the result just computed: explanations for keys that no longer exist drop out.
|
|
30
|
+
export function reguard(result: HuntResult, s: HuntSynthesis): HuntSynthesis {
|
|
31
|
+
const g = guardSynthesis(result, { summary: s.summary, explanations: s.explanations }, { provider: s.provider, model: s.model, usage: s.usage });
|
|
32
|
+
return { ...g, status: s.status, stop: s.stop, detail: s.detail };
|
|
33
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Loads the detector rule files (detectors/*.json). Detectors are data; this file only reads and checks them.
|
|
2
|
+
// The format is documented in docs/INTERFACES.md, section "Detector rule files".
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { STACK_CATEGORIES } from '../types/stack.ts';
|
|
8
|
+
import type { Confidence, StackCategory } from '../types/stack.ts';
|
|
9
|
+
import { cmp } from '../util.ts';
|
|
10
|
+
|
|
11
|
+
export interface Signal {
|
|
12
|
+
files: string[]; // globs
|
|
13
|
+
regex?: string; // when absent, the file being there is the signal
|
|
14
|
+
all?: string[]; // further patterns that must also match somewhere in the same file
|
|
15
|
+
ignore_case?: boolean;
|
|
16
|
+
confidence?: Confidence; // default: high for a file signal, medium for a content signal
|
|
17
|
+
attrs?: Record<string, string>; // list fields to add to the item; "$file" is replaced by the file path
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DetectorEntry {
|
|
21
|
+
id: string;
|
|
22
|
+
attrs?: Record<string, string>; // scalar fields of the item, for example {"kind": "cloud"}
|
|
23
|
+
extensions?: string[]; // languages only
|
|
24
|
+
deps?: Record<string, string[]>; // ecosystem to package names; "x*" is a prefix
|
|
25
|
+
deps_confidence?: Confidence | Record<string, Confidence>; // default high; an object sets it per ecosystem, "*" for the rest
|
|
26
|
+
imports?: Record<string, string[]>; // import language key to module names; "x*" is a prefix
|
|
27
|
+
imports_confidence?: Confidence; // default medium
|
|
28
|
+
signals?: Signal[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DetectorFile { category: StackCategory; entries: DetectorEntry[] }
|
|
32
|
+
|
|
33
|
+
export const DETECTORS_DIR = fileURLToPath(new URL('../../detectors/', import.meta.url));
|
|
34
|
+
|
|
35
|
+
const CONF = new Set(['high', 'medium', 'low']);
|
|
36
|
+
|
|
37
|
+
function check(name: string, f: DetectorFile): void {
|
|
38
|
+
const bad = (msg: string): never => { throw new Error(`detectors/${name}: ${msg}`); };
|
|
39
|
+
if (!STACK_CATEGORIES.includes(f.category)) bad(`unknown category ${f.category}`);
|
|
40
|
+
const ids = new Set<string>();
|
|
41
|
+
for (const e of f.entries ?? bad('no entries')) {
|
|
42
|
+
if (!/^[a-z0-9][a-z0-9.-]*$/.test(e.id ?? '')) bad(`bad id ${e.id}`);
|
|
43
|
+
if (ids.has(e.id)) bad(`duplicate id ${e.id}`);
|
|
44
|
+
ids.add(e.id);
|
|
45
|
+
if (!e.deps && !e.imports && !e.signals && !e.extensions) bad(`${e.id} has no signal`);
|
|
46
|
+
const dc = typeof e.deps_confidence === 'object' ? Object.values(e.deps_confidence) : [e.deps_confidence];
|
|
47
|
+
for (const c of [...dc, e.imports_confidence, ...(e.signals ?? []).map((s) => s.confidence)]) {
|
|
48
|
+
if (c !== undefined && !CONF.has(c)) bad(`${e.id} bad confidence ${c}`);
|
|
49
|
+
}
|
|
50
|
+
for (const s of e.signals ?? []) {
|
|
51
|
+
if (!Array.isArray(s.files) || s.files.length === 0) bad(`${e.id} signal without files`);
|
|
52
|
+
for (const r of [s.regex, ...(s.all ?? [])]) if (r !== undefined) new RegExp(r); // throws on a bad pattern
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface LoadedDetectors { files: DetectorFile[]; sha256: string }
|
|
58
|
+
|
|
59
|
+
export function loadDetectors(dir: string = DETECTORS_DIR): LoadedDetectors {
|
|
60
|
+
const names = readdirSync(dir).filter((n) => n.endsWith('.json')).sort(cmp);
|
|
61
|
+
const sums: string[] = [];
|
|
62
|
+
const files: DetectorFile[] = [];
|
|
63
|
+
for (const n of names) {
|
|
64
|
+
const raw = readFileSync(join(dir, n));
|
|
65
|
+
sums.push(`${createHash('sha256').update(raw).digest('hex')} ${n}\n`);
|
|
66
|
+
const f = JSON.parse(raw.toString('utf8')) as DetectorFile;
|
|
67
|
+
check(n, f);
|
|
68
|
+
files.push(f);
|
|
69
|
+
}
|
|
70
|
+
return { files, sha256: createHash('sha256').update(sums.join('')).digest('hex') };
|
|
71
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// Stack discovery: deterministic, no model, no network, runs no program. Lists files, extracts facts, applies the detector
|
|
2
|
+
// rules, and builds stack.json. Same files and same detector files give the same bytes.
|
|
3
|
+
import { closeSync, openSync, readSync, fstatSync } from 'node:fs';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import type { Confidence, Evidence, Stack, StackCategory, StackItem, Unknown } from '../types/stack.ts';
|
|
6
|
+
import { STACK_CATEGORIES } from '../types/stack.ts';
|
|
7
|
+
import { cmp, globToRegExp, sortUnique } from '../util.ts';
|
|
8
|
+
import { ENGINE_VERSION } from '../version.ts';
|
|
9
|
+
import { loadDetectors } from './detectors.ts';
|
|
10
|
+
import type { DetectorEntry, LoadedDetectors, Signal } from './detectors.ts';
|
|
11
|
+
import { IMPORT_LANGS, MANIFEST_NAMES, extractDeps, extractImports, importLangOf, nameMatches } from './extract.ts';
|
|
12
|
+
import type { DepFact, ImportFact } from './extract.ts';
|
|
13
|
+
import { readHeadCommit } from './git.ts';
|
|
14
|
+
import { walk } from './walk.ts';
|
|
15
|
+
|
|
16
|
+
export const LIMITS = { maxFiles: 200_000, maxBytesPerFile: 512 * 1024, maxImportFiles: 50_000, evidencePerItem: 5, languageSamples: 3 };
|
|
17
|
+
|
|
18
|
+
// Separator for prefix matching of package names per ecosystem; '' means exact names only (use "x*" for a prefix).
|
|
19
|
+
const DEP_SEP: Record<string, string> = {
|
|
20
|
+
npm: '/', composer: '/', go: '/', maven: ':', nuget: '.', cocoapods: '/', swiftpm: '/',
|
|
21
|
+
pypi: '', cargo: '', rubygems: '', pub: '', hex: '', terraform: '',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Extensions that are data, docs, media or build artefacts: neither a language nor worth listing as unknown.
|
|
25
|
+
const NOT_CODE = new Set(('md markdown mdx txt rst adoc json jsonc json5 yaml yml toml xml csv tsv ini cfg conf lock log ' +
|
|
26
|
+
'png jpg jpeg gif svg ico webp bmp tif tiff avif heic mp3 mp4 m4a wav ogg webm mov avi pdf zip gz tgz tar bz2 xz 7z rar jar war ' +
|
|
27
|
+
'woff woff2 ttf otf eot map snap example sample template tpl j2 properties plist pbxproj xcconfig xcscheme xcworkspacedata ' +
|
|
28
|
+
'storyboard xib entitlements strings pem crt cer key pub gitkeep keep sqlite sqlite3 db bin dat pkl pickle h5 npy ' +
|
|
29
|
+
'sum mod tfvars tfstate hcl lockb editorconfig env patch diff po pot mo xlsx xls docx doc pptx odt license gradle bat cmd ' +
|
|
30
|
+
'iml http rest bak orig tmp swp dockerignore gitignore gitattributes npmrc nvmrc prettierrc eslintrc babelrc ipynb').split(/\s+/));
|
|
31
|
+
|
|
32
|
+
const LOCKFILE = /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|composer\.lock|packages\.lock\.json|[^/]+\.lock)$/;
|
|
33
|
+
// Configuration files are looked for at the repository root and one level inside dot folders (.github, .do, .gitlab).
|
|
34
|
+
const CONFIG_EXT = /\.(ya?ml|toml|json|jsonc|ini|cfg|conf|hcl|properties)$/;
|
|
35
|
+
// Common tool configuration with no security meaning for discovery; not listed as unknown.
|
|
36
|
+
const KNOWN_TOOLING = /(^|\/)(\.?(eslint|prettier|stylelint|babel|jest|nyc|mocha|lint-staged|commitlint|markdownlint|yamllint|remark|cspell|renovate|codecov|release-it|semantic-release|browserslist|swcrc|postcss|tailwind|editorconfig|husky|c8|ava|vitest|karma|protractor|typedoc|tslint|lerna|nx|turbo|crowdin|codeclimate|mailmap|devcontainer|dependabot|pre-commit-config|gitpod|vscode|readthedocs|mkdocs|coveralls|sonar-project|cypress|playwright|stryker|snyk|zap|ncurc|npmpackagejsonlintrc|jsdoc|jsconfig|typos)(rc)?(\.[^/]*)?|tsconfig[^/]*|package-lock\.json|composer\.lock|\.?github\/[^/]*\.ya?ml|\.?github\/FUNDING\.yml|bower\.json|manifest\.json)$/i;
|
|
37
|
+
const TF_UTILITY = new Set(['random', 'null', 'local', 'tls', 'time', 'archive', 'template', 'external', 'http', 'terraform', 'cloudinit']);
|
|
38
|
+
|
|
39
|
+
const depConf = (e: DetectorEntry, eco: string): Confidence => {
|
|
40
|
+
const c = e.deps_confidence;
|
|
41
|
+
return (typeof c === 'object' ? c[eco] ?? c['*'] : c) ?? 'high';
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const RANK: Record<Confidence, number> = { high: 0, medium: 1, low: 2 };
|
|
45
|
+
|
|
46
|
+
interface Acc { evidence: Evidence[]; versions: Set<string>; lists: Map<string, Set<string>>; scalars: Record<string, string>; fileCount?: number }
|
|
47
|
+
|
|
48
|
+
function readHead(abs: string, max: number): { text: string; truncated: boolean } {
|
|
49
|
+
const fd = openSync(abs, 'r');
|
|
50
|
+
try {
|
|
51
|
+
const size = fstatSync(fd).size;
|
|
52
|
+
const buf = Buffer.alloc(Math.min(size, max));
|
|
53
|
+
const n = readSync(fd, buf, 0, buf.length, 0);
|
|
54
|
+
return { text: buf.subarray(0, n).toString('utf8'), truncated: size > max };
|
|
55
|
+
} finally { closeSync(fd); }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const lineAt = (text: string, index: number): number => {
|
|
59
|
+
let n = 1;
|
|
60
|
+
for (let i = 0; i < index; i++) if (text.charCodeAt(i) === 10) n++;
|
|
61
|
+
return n;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const extOf = (f: string): string => {
|
|
65
|
+
const b = f.slice(f.lastIndexOf('/') + 1);
|
|
66
|
+
const d = b.lastIndexOf('.');
|
|
67
|
+
return d > 0 ? b.slice(d + 1).toLowerCase() : '';
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export interface DiscoverOptions { detectors?: LoadedDetectors; skipFiles?: ReadonlySet<string> } // skipFiles: unchanged kit files (kit-files.ts)
|
|
71
|
+
|
|
72
|
+
export function discover(repo: string, opts: DiscoverOptions = {}): Stack {
|
|
73
|
+
const root = resolve(repo);
|
|
74
|
+
const det = opts.detectors ?? loadDetectors();
|
|
75
|
+
const listing = walk(root, LIMITS.maxFiles, opts.skipFiles);
|
|
76
|
+
const files = listing.files;
|
|
77
|
+
|
|
78
|
+
const cache = new Map<string, string>();
|
|
79
|
+
const truncated = new Set<string>();
|
|
80
|
+
const text = (f: string): string => {
|
|
81
|
+
let t = cache.get(f);
|
|
82
|
+
if (t === undefined) {
|
|
83
|
+
try {
|
|
84
|
+
const r = readHead(join(root, f), LIMITS.maxBytesPerFile);
|
|
85
|
+
t = r.text;
|
|
86
|
+
if (r.truncated) truncated.add(f);
|
|
87
|
+
} catch { t = ''; }
|
|
88
|
+
cache.set(f, t);
|
|
89
|
+
}
|
|
90
|
+
return t;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// Facts.
|
|
94
|
+
const deps: DepFact[] = [];
|
|
95
|
+
for (const f of files) if (MANIFEST_NAMES.test(f)) deps.push(...extractDeps(f, text(f)));
|
|
96
|
+
const importFiles = files.filter((f) => importLangOf(f) !== null);
|
|
97
|
+
const scanned = importFiles.slice(0, LIMITS.maxImportFiles);
|
|
98
|
+
const imports: ImportFact[] = [];
|
|
99
|
+
for (const f of scanned) {
|
|
100
|
+
imports.push(...extractImports(f, text(f)));
|
|
101
|
+
cache.delete(f); // source text is not needed again unless a content signal asks for it
|
|
102
|
+
}
|
|
103
|
+
const depsByEco = Map.groupBy(deps, (d) => d.eco);
|
|
104
|
+
const importsByLang = Map.groupBy(imports, (i) => i.lang);
|
|
105
|
+
|
|
106
|
+
// Items.
|
|
107
|
+
const items = new Map<string, Acc>();
|
|
108
|
+
const usedFiles = new Set<string>();
|
|
109
|
+
const matchedTf = new Set<string>();
|
|
110
|
+
const acc = (cat: StackCategory, id: string): Acc => {
|
|
111
|
+
const k = `${cat}\u0000${id}`;
|
|
112
|
+
let a = items.get(k);
|
|
113
|
+
if (!a) { a = { evidence: [], versions: new Set(), lists: new Map(), scalars: {} }; items.set(k, a); }
|
|
114
|
+
return a;
|
|
115
|
+
};
|
|
116
|
+
const add = (a: Acc, ev: Evidence): void => { a.evidence.push(ev); usedFiles.add(ev.file); };
|
|
117
|
+
const addList = (a: Acc, attrs: Record<string, string> | undefined, file: string): void => {
|
|
118
|
+
for (const [k, v] of Object.entries(attrs ?? {})) {
|
|
119
|
+
if (!a.lists.has(k)) a.lists.set(k, new Set());
|
|
120
|
+
a.lists.get(k)!.add(v === '$file' ? file : v);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const globCache = new Map<string, string[]>();
|
|
124
|
+
const filesFor = (globs: string[]): string[] => {
|
|
125
|
+
const key = globs.join('\n');
|
|
126
|
+
let r = globCache.get(key);
|
|
127
|
+
if (!r) { const res = globs.map(globToRegExp); r = files.filter((f) => res.some((re) => re.test(f))); globCache.set(key, r); }
|
|
128
|
+
return r;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const runSignal = (cat: StackCategory, e: DetectorEntry, s: Signal, i: number): void => {
|
|
132
|
+
const kind = s.regex ? 'content' : 'file';
|
|
133
|
+
const rule = `${cat}.${e.id}.${kind}.${i + 1}`;
|
|
134
|
+
const conf: Confidence = s.confidence ?? (s.regex ? 'medium' : 'high');
|
|
135
|
+
const flags = 'm' + (s.ignore_case ? 'i' : '');
|
|
136
|
+
const re = s.regex ? new RegExp(s.regex, flags) : null;
|
|
137
|
+
const all = (s.all ?? []).map((r) => new RegExp(r, flags));
|
|
138
|
+
for (const f of filesFor(s.files)) {
|
|
139
|
+
if (re && LOCKFILE.test(f)) continue; // lockfiles are never templates or config; reading them only costs time
|
|
140
|
+
if (!re) { const a = acc(cat, e.id); add(a, { file: f, key: '(present)', rule, confidence: conf }); addList(a, s.attrs, f); continue; }
|
|
141
|
+
const t = text(f);
|
|
142
|
+
const m = re.exec(t);
|
|
143
|
+
if (!m || !all.every((r) => r.test(t))) continue;
|
|
144
|
+
const a = acc(cat, e.id);
|
|
145
|
+
add(a, { file: f, line: lineAt(t, m.index), rule, confidence: conf });
|
|
146
|
+
addList(a, s.attrs, f);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
for (const df of det.files) {
|
|
151
|
+
const cat = df.category;
|
|
152
|
+
for (const e of df.entries) {
|
|
153
|
+
for (const [eco, names] of Object.entries(e.deps ?? {})) {
|
|
154
|
+
for (const d of depsByEco.get(eco) ?? []) {
|
|
155
|
+
if (!names.some((n) => nameMatches(d.name, n, DEP_SEP[eco] ?? ''))) continue;
|
|
156
|
+
if (eco === 'terraform') matchedTf.add(d.name);
|
|
157
|
+
const a = acc(cat, e.id);
|
|
158
|
+
add(a, { file: d.file, ...(d.line !== undefined ? { line: d.line } : { key: d.key }), rule: `${cat}.${e.id}.dep.${eco}`, confidence: depConf(e, eco) });
|
|
159
|
+
if (d.version) a.versions.add(`${d.name}@${d.version}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const [lang, mods] of Object.entries(e.imports ?? {})) {
|
|
163
|
+
const sep = IMPORT_LANGS[lang]?.sep ?? '';
|
|
164
|
+
for (const im of importsByLang.get(lang) ?? []) {
|
|
165
|
+
if (!mods.some((m) => nameMatches(im.module, m, sep))) continue;
|
|
166
|
+
add(acc(cat, e.id), { file: im.file, line: im.line, rule: `${cat}.${e.id}.import.${lang}`, confidence: e.imports_confidence ?? 'medium' });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
(e.signals ?? []).forEach((s, i) => runSignal(cat, e, s, i));
|
|
170
|
+
if (e.extensions) {
|
|
171
|
+
const exts = new Set(e.extensions);
|
|
172
|
+
const hits = files.filter((f) => exts.has(extOf(f)));
|
|
173
|
+
if (hits.length) {
|
|
174
|
+
const a = acc(cat, e.id);
|
|
175
|
+
a.fileCount = hits.length;
|
|
176
|
+
for (const f of hits) a.evidence.push({ file: f, key: '(extension)', rule: `${cat}.${e.id}.extension`, confidence: 'high' });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const a = items.get(`${cat}\u0000${e.id}`);
|
|
180
|
+
if (a) Object.assign(a.scalars, e.attrs ?? {});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Unknowns: never guessed, only listed.
|
|
185
|
+
const unknown: Unknown[] = [];
|
|
186
|
+
const langExts = new Set(det.files.filter((f) => f.category === 'languages').flatMap((f) => f.entries.flatMap((e) => e.extensions ?? [])));
|
|
187
|
+
const byExt = new Map<string, string[]>();
|
|
188
|
+
for (const f of files) {
|
|
189
|
+
const x = extOf(f);
|
|
190
|
+
if (!x || langExts.has(x) || NOT_CODE.has(x)) continue;
|
|
191
|
+
if (!byExt.has(x)) byExt.set(x, []);
|
|
192
|
+
byExt.get(x)!.push(f);
|
|
193
|
+
}
|
|
194
|
+
for (const [x, fs] of byExt) unknown.push({ file: fs[0], note: `no detector for the file extension .${x} (${fs.length} file${fs.length === 1 ? '' : 's'})` });
|
|
195
|
+
for (const f of files) {
|
|
196
|
+
const parts = f.split('/');
|
|
197
|
+
const configPlace = parts.length === 1 || (parts.length === 2 && parts[0].startsWith('.'));
|
|
198
|
+
if (!configPlace || !CONFIG_EXT.test(f) || usedFiles.has(f) || KNOWN_TOOLING.test(f)) continue;
|
|
199
|
+
unknown.push({ file: f, note: 'configuration file with no detector' });
|
|
200
|
+
}
|
|
201
|
+
const tfSeen = new Set<string>();
|
|
202
|
+
for (const d of depsByEco.get('terraform') ?? []) {
|
|
203
|
+
if (matchedTf.has(d.name) || TF_UTILITY.has(d.name) || tfSeen.has(d.name)) continue;
|
|
204
|
+
tfSeen.add(d.name);
|
|
205
|
+
unknown.push({ file: d.file, note: `Terraform provider "${d.name}" has no detector` });
|
|
206
|
+
}
|
|
207
|
+
const LOCKS = ['package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb', 'bun.lock'];
|
|
208
|
+
const fileSet = new Set(files);
|
|
209
|
+
for (const f of files.filter((x) => x === 'package.json' || x.endsWith('/package.json'))) {
|
|
210
|
+
const dir = f.slice(0, -'package.json'.length);
|
|
211
|
+
if (LOCKS.some((l) => fileSet.has(dir + l) || fileSet.has(l))) continue;
|
|
212
|
+
unknown.push({ file: f, note: 'package.json with no lockfile in its folder or the repository root: package manager not determined' });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Assemble, sorted.
|
|
216
|
+
const out = Object.fromEntries(STACK_CATEGORIES.map((c) => [c, [] as StackItem[]])) as Record<StackCategory, StackItem[]>;
|
|
217
|
+
for (const [k, a] of items) {
|
|
218
|
+
const [cat, id] = k.split('\u0000') as [StackCategory, string];
|
|
219
|
+
const seen = new Set<string>();
|
|
220
|
+
const ev = a.evidence
|
|
221
|
+
.sort((x, y) => RANK[x.confidence] - RANK[y.confidence] || cmp(x.file, y.file) || (x.line ?? 0) - (y.line ?? 0) || cmp(x.key ?? '', y.key ?? '') || cmp(x.rule, y.rule))
|
|
222
|
+
.filter((x) => { const s = `${x.file}|${x.line}|${x.key}|${x.rule}`; return seen.has(s) ? false : (seen.add(s), true); });
|
|
223
|
+
const shown = cat === 'languages' && a.fileCount ? LIMITS.languageSamples : LIMITS.evidencePerItem;
|
|
224
|
+
// The sample shows the best entry of each file first, so it spans files instead of repeating one file.
|
|
225
|
+
const fileSeen = new Set<string>();
|
|
226
|
+
const firsts = ev.filter((x) => !fileSeen.has(x.file) && !!fileSeen.add(x.file));
|
|
227
|
+
const firstSet = new Set(firsts);
|
|
228
|
+
const sample = [...firsts, ...ev.filter((x) => !firstSet.has(x))].slice(0, shown);
|
|
229
|
+
const item: StackItem = { id, confidence: ev[0].confidence, evidence: sample, evidence_count: ev.length, ...a.scalars };
|
|
230
|
+
if (a.versions.size) item.versions = sortUnique(a.versions);
|
|
231
|
+
if (a.fileCount) item.file_count = a.fileCount;
|
|
232
|
+
for (const [lk, set] of a.lists) (item as unknown as Record<string, string[]>)[lk] = sortUnique(set);
|
|
233
|
+
out[cat].push(item);
|
|
234
|
+
}
|
|
235
|
+
for (const c of STACK_CATEGORIES) out[c].sort((x, y) => cmp(x.id, y.id));
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
schema_version: '1.0.0',
|
|
239
|
+
engine_version: ENGINE_VERSION,
|
|
240
|
+
detectors_sha256: det.sha256,
|
|
241
|
+
commit: readHeadCommit(root),
|
|
242
|
+
confirmed_by_user: false,
|
|
243
|
+
files: { listed: files.length, scanned_for_imports: scanned.length },
|
|
244
|
+
limits: {
|
|
245
|
+
max_files: LIMITS.maxFiles, max_bytes_per_file: LIMITS.maxBytesPerFile,
|
|
246
|
+
file_cap_reached: listing.capReached || importFiles.length > scanned.length,
|
|
247
|
+
truncated_files: sortUnique(truncated),
|
|
248
|
+
},
|
|
249
|
+
skipped: listing.skipped.slice().sort((x, y) => cmp(x.path, y.path)),
|
|
250
|
+
...out,
|
|
251
|
+
unknown: unknown.sort((x, y) => cmp(x.file, y.file) || cmp(x.note, y.note)),
|
|
252
|
+
};
|
|
253
|
+
}
|