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,145 @@
|
|
|
1
|
+
export const MAX_DEPTH = 8;
|
|
2
|
+
export const MAX_NODES = 60;
|
|
3
|
+
export const WITNESS_SLACK = 2; // a fact counts when its line is within this many lines of a cited line
|
|
4
|
+
export const TOO_BROAD_MIN = 3; // a query that matches every entry point of a map this large proves nothing
|
|
5
|
+
const OPS = new Set(['eq', 'ne', 'in', 'contains', 'startsWith', 'exists', 'gt', 'lt']);
|
|
6
|
+
const FIELD = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
7
|
+
const BAD_SEG = new Set(['__proto__', 'constructor', 'prototype']);
|
|
8
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
9
|
+
const goodField = (f) => typeof f === 'string' && FIELD.test(f) && !f.split('.').some((s) => BAD_SEG.has(s));
|
|
10
|
+
export function parseQuery(v) {
|
|
11
|
+
if (!isObj(v) || !('where' in v))
|
|
12
|
+
return { ok: false, reason: 'a query is {"where": condition}' };
|
|
13
|
+
let nodes = 0;
|
|
14
|
+
const cond = (c, depth) => {
|
|
15
|
+
if (depth > MAX_DEPTH)
|
|
16
|
+
return `nested deeper than ${MAX_DEPTH}`;
|
|
17
|
+
if (++nodes > MAX_NODES)
|
|
18
|
+
return `more than ${MAX_NODES} conditions`;
|
|
19
|
+
if (!isObj(c))
|
|
20
|
+
return 'a condition must be an object';
|
|
21
|
+
for (const k of ['all', 'any']) {
|
|
22
|
+
if (k in c) {
|
|
23
|
+
if (!Array.isArray(c[k]) || c[k].length === 0)
|
|
24
|
+
return `${k} needs a non empty list`;
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const x of c[k]) {
|
|
27
|
+
const r = cond(x, depth + 1);
|
|
28
|
+
if (typeof r === 'string')
|
|
29
|
+
return r;
|
|
30
|
+
out.push(r);
|
|
31
|
+
}
|
|
32
|
+
return k === 'all' ? { all: out } : { any: out };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if ('not' in c) {
|
|
36
|
+
const r = cond(c.not, depth + 1);
|
|
37
|
+
return typeof r === 'string' ? r : { not: r };
|
|
38
|
+
}
|
|
39
|
+
for (const k of ['some', 'none']) {
|
|
40
|
+
if (k in c) {
|
|
41
|
+
if (!goodField(c[k]))
|
|
42
|
+
return `${k} needs a field name`;
|
|
43
|
+
let where;
|
|
44
|
+
if (c.where !== undefined) {
|
|
45
|
+
const r = cond(c.where, depth + 1);
|
|
46
|
+
if (typeof r === 'string')
|
|
47
|
+
return r;
|
|
48
|
+
where = r;
|
|
49
|
+
}
|
|
50
|
+
return k === 'some' ? { some: c.some, ...(where ? { where } : {}) } : { none: c.none, ...(where ? { where } : {}) };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if ('field' in c) {
|
|
54
|
+
if (!goodField(c.field))
|
|
55
|
+
return 'field is not a plain dotted name';
|
|
56
|
+
if (typeof c.op !== 'string' || !OPS.has(c.op))
|
|
57
|
+
return `op is one of ${[...OPS].join(', ')}`;
|
|
58
|
+
const val = c.value;
|
|
59
|
+
const scalar = (x) => x === null || ['string', 'number', 'boolean'].includes(typeof x);
|
|
60
|
+
if (c.op === 'in' ? !Array.isArray(val) || !val.every(scalar) || val.length > 20 : c.op === 'exists' ? val !== undefined && typeof val !== 'boolean' : !scalar(val))
|
|
61
|
+
return `value does not fit op ${c.op}`;
|
|
62
|
+
if ((c.op === 'gt' || c.op === 'lt') && typeof val !== 'number')
|
|
63
|
+
return `${c.op} needs a number`;
|
|
64
|
+
if ((c.op === 'startsWith') && typeof val !== 'string')
|
|
65
|
+
return 'startsWith needs a string';
|
|
66
|
+
return { field: c.field, op: c.op, ...(val !== undefined ? { value: val } : {}) };
|
|
67
|
+
}
|
|
68
|
+
return 'a condition is all, any, not, some, none or field';
|
|
69
|
+
};
|
|
70
|
+
const r = cond(v.where, 1);
|
|
71
|
+
return typeof r === 'string' ? { ok: false, reason: r } : { ok: true, query: { where: r } };
|
|
72
|
+
}
|
|
73
|
+
const NO = { ok: false, w: [] };
|
|
74
|
+
function pick(node, path) {
|
|
75
|
+
let cur = node;
|
|
76
|
+
for (const seg of path.split('.')) {
|
|
77
|
+
if (!isObj(cur) || !Object.hasOwn(cur, seg))
|
|
78
|
+
return undefined;
|
|
79
|
+
cur = cur[seg];
|
|
80
|
+
}
|
|
81
|
+
return cur;
|
|
82
|
+
}
|
|
83
|
+
function compare(c, node) {
|
|
84
|
+
const got = pick(node, c.field);
|
|
85
|
+
const want = c.value;
|
|
86
|
+
switch (c.op) {
|
|
87
|
+
case 'exists': return (got !== undefined && got !== null) === (want !== false);
|
|
88
|
+
case 'eq': return got === want;
|
|
89
|
+
case 'ne': return got !== want;
|
|
90
|
+
case 'in': return Array.isArray(want) && want.includes(got);
|
|
91
|
+
case 'contains': return Array.isArray(got) ? got.includes(want) : typeof got === 'string' && typeof want === 'string' && got.includes(want);
|
|
92
|
+
case 'startsWith': return typeof got === 'string' && typeof want === 'string' && got.startsWith(want);
|
|
93
|
+
case 'gt': return typeof got === 'number' && typeof want === 'number' && got > want;
|
|
94
|
+
case 'lt': return typeof got === 'number' && typeof want === 'number' && got < want;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function run(c, node, file) {
|
|
98
|
+
if ('all' in c) {
|
|
99
|
+
const rs = c.all.map((x) => run(x, node, file));
|
|
100
|
+
return rs.every((r) => r.ok) ? { ok: true, w: rs.flatMap((r) => r.w) } : NO;
|
|
101
|
+
}
|
|
102
|
+
if ('any' in c) {
|
|
103
|
+
const rs = c.any.map((x) => run(x, node, file)).filter((r) => r.ok);
|
|
104
|
+
return rs.length ? { ok: true, w: rs.flatMap((r) => r.w) } : NO;
|
|
105
|
+
}
|
|
106
|
+
if ('not' in c)
|
|
107
|
+
return run(c.not, node, file).ok ? NO : { ok: true, w: [] };
|
|
108
|
+
if ('some' in c || 'none' in c) {
|
|
109
|
+
const f = 'some' in c ? c.some : c.none;
|
|
110
|
+
const arr = pick(node, f);
|
|
111
|
+
const items = Array.isArray(arr) ? arr : [];
|
|
112
|
+
const hits = items.filter((it) => !c.where || run(c.where, it, isObj(it) && typeof it.file === 'string' ? it.file : file).ok);
|
|
113
|
+
if ('none' in c)
|
|
114
|
+
return hits.length === 0 ? { ok: true, w: [] } : NO;
|
|
115
|
+
if (!hits.length)
|
|
116
|
+
return NO;
|
|
117
|
+
const w = [];
|
|
118
|
+
for (const it of hits)
|
|
119
|
+
if (isObj(it) && typeof it.line === 'number')
|
|
120
|
+
w.push({ file: typeof it.file === 'string' ? it.file : file, line: it.line });
|
|
121
|
+
return { ok: true, w };
|
|
122
|
+
}
|
|
123
|
+
return compare(c, node) ? { ok: true, w: [] } : NO;
|
|
124
|
+
}
|
|
125
|
+
// 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
|
|
126
|
+
// 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
|
|
127
|
+
// matches every entry point of a map of TOO_BROAD_MIN or more proves nothing and is not accepted.
|
|
128
|
+
export function runQuery(map, q, entryId, cited) {
|
|
129
|
+
const ep = map.entry_points.find((e) => e.id === entryId);
|
|
130
|
+
if (!ep)
|
|
131
|
+
return { confirmed: false, detail: 'the entry point is not in the code map', witnesses: [] };
|
|
132
|
+
const at = (e) => run(q.where, e, e.handler?.file ?? e.file);
|
|
133
|
+
const mine = at(ep);
|
|
134
|
+
if (!mine.ok)
|
|
135
|
+
return { confirmed: false, detail: 'the query does not match this entry point', witnesses: [] };
|
|
136
|
+
const matched = map.entry_points.filter((e) => at(e).ok).length;
|
|
137
|
+
if (map.entry_points.length >= TOO_BROAD_MIN && matched === map.entry_points.length) {
|
|
138
|
+
return { confirmed: false, detail: `the query matches all ${matched} entry points, so it proves nothing about this one`, witnesses: [] };
|
|
139
|
+
}
|
|
140
|
+
const near = mine.w.filter((w) => cited.some((s) => s.file === w.file && Math.abs(s.line - w.line) <= WITNESS_SLACK));
|
|
141
|
+
if (!near.length)
|
|
142
|
+
return { confirmed: false, detail: 'no fact the query matched sits on a line the lead cites', witnesses: [] };
|
|
143
|
+
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));
|
|
144
|
+
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 };
|
|
145
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
const TIMEOUT_MS = 120_000;
|
|
11
|
+
const SLACK = 1;
|
|
12
|
+
export const spawnSemgrep = (args) => {
|
|
13
|
+
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' } });
|
|
14
|
+
if (r.error?.code === 'ENOENT')
|
|
15
|
+
return { status: null, stdout: '', missing: true };
|
|
16
|
+
return { status: r.status, stdout: r.stdout ?? '' };
|
|
17
|
+
};
|
|
18
|
+
const BANNED = /pattern-where-python|pattern-sources:\s*\n\s*-\s*exact|\binclude:|r2c-internal/;
|
|
19
|
+
function scanFile(run, rule, target) {
|
|
20
|
+
const r = run(['scan', '--config', rule, '--json', '--quiet', '--metrics=off', '--disable-version-check', '--no-git-ignore', '--timeout', '10', target]);
|
|
21
|
+
if (r.missing)
|
|
22
|
+
return { missing: true };
|
|
23
|
+
let out;
|
|
24
|
+
try {
|
|
25
|
+
out = JSON.parse(r.stdout);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return { fail: 'semgrep printed no JSON for this rule' };
|
|
29
|
+
}
|
|
30
|
+
if (!Array.isArray(out.results))
|
|
31
|
+
return { fail: 'semgrep printed no results list' };
|
|
32
|
+
if (Array.isArray(out.errors) && out.errors.length)
|
|
33
|
+
return { fail: 'semgrep reported an error for the rule or the file' };
|
|
34
|
+
if (!(out.paths?.scanned ?? []).some((p) => resolve(p) === resolve(target)))
|
|
35
|
+
return { fail: 'semgrep did not scan the file (the rule does not cover its language)' };
|
|
36
|
+
return { out };
|
|
37
|
+
}
|
|
38
|
+
export function confirmRule(lead, repo, run = spawnSemgrep) {
|
|
39
|
+
const p = lead.evidence_proposal;
|
|
40
|
+
if (p.kind !== 'semgrep_rule')
|
|
41
|
+
return { status: 'not_confirmed', detail: 'no rule was proposed' };
|
|
42
|
+
const ids = [...p.rule_yaml.matchAll(/^\s*-\s*id:\s*([^\s#]+)/gm)].map((m) => m[1]);
|
|
43
|
+
if (!/^\s*rules:/m.test(p.rule_yaml) || ids.length !== 1)
|
|
44
|
+
return { status: 'not_confirmed', detail: 'the rule file must hold exactly one rule' };
|
|
45
|
+
if (!/^[A-Za-z0-9._-]{1,100}$/.test(ids[0]))
|
|
46
|
+
return { status: 'not_confirmed', detail: 'the rule id has characters outside letters, digits, dot, underscore and hyphen' };
|
|
47
|
+
if (BANNED.test(p.rule_yaml))
|
|
48
|
+
return { status: 'not_confirmed', detail: 'the rule uses a feature the engine does not accept' };
|
|
49
|
+
const norm = (s) => s.replace(/\s+/g, ' ').trim();
|
|
50
|
+
if (norm(p.control_code).includes(norm(lead.quoted_code)))
|
|
51
|
+
return { status: 'not_confirmed', detail: 'the negative control still holds the quoted code, so it is not a control' };
|
|
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)
|
|
61
|
+
return { status: 'not_run', detail: 'Semgrep is not installed' };
|
|
62
|
+
const hit = scanFile(run, ruleFile, join(resolve(repo), sink.file));
|
|
63
|
+
if ('missing' in hit)
|
|
64
|
+
return { status: 'not_run', detail: 'Semgrep is not installed' };
|
|
65
|
+
if ('fail' in hit)
|
|
66
|
+
return { status: 'not_confirmed', detail: hit.fail };
|
|
67
|
+
const onSink = hit.out.results.some((r) => r.end.line >= sink.line - SLACK && r.start.line <= sink.line + SLACK);
|
|
68
|
+
if (!onSink)
|
|
69
|
+
return { status: 'not_confirmed', detail: `the rule does not match the cited lines (${sink.file}:${sink.line})` };
|
|
70
|
+
const ctl = scanFile(run, ruleFile, controlFile);
|
|
71
|
+
if ('missing' in ctl)
|
|
72
|
+
return { status: 'not_run', detail: 'Semgrep is not installed' };
|
|
73
|
+
if ('fail' in ctl)
|
|
74
|
+
return { status: 'not_confirmed', detail: `negative control: ${ctl.fail}` };
|
|
75
|
+
if (ctl.out.results.length)
|
|
76
|
+
return { status: 'not_confirmed', detail: 'the rule also matches the negative control, so it does not tell the flaw from safe code' };
|
|
77
|
+
return {
|
|
78
|
+
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),
|
|
79
|
+
tool_version: (/^\d+\.\d+[\w.+-]*/m.exec(ver.stdout)?.[0] ?? 'unknown').replace(/[^A-Za-z0-9._+-]/g, '-').slice(0, 59) || 'unknown',
|
|
80
|
+
config_sha256: createHash('sha256').update(p.rule_yaml).digest('hex'),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
rmSync(dir, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -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 { cmp, stableJson } from '../util.js';
|
|
7
|
+
export const huntDir = (scanDir) => join(scanDir, 'hunt');
|
|
8
|
+
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'entry';
|
|
9
|
+
export const answerFile = (hunt, run, entryId) => join(hunt, `run-${run}`, `${slug(entryId)}-${createHash('sha256').update(entryId).digest('hex').slice(0, 8)}.json`);
|
|
10
|
+
function writeAtomic(path, text) {
|
|
11
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
12
|
+
const tmp = `${path}.tmp`;
|
|
13
|
+
writeFileSync(tmp, text);
|
|
14
|
+
renameSync(tmp, path);
|
|
15
|
+
}
|
|
16
|
+
const readJson = (p) => { try {
|
|
17
|
+
return JSON.parse(readFileSync(p, 'utf8'));
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
throw new Error(`${p} cannot be read: ${e.message}`);
|
|
21
|
+
} };
|
|
22
|
+
export const writeAnswer = (hunt, a) => writeAtomic(answerFile(hunt, a.run, a.entry_id), stableJson(a));
|
|
23
|
+
export const hasAnswer = (hunt, run, entryId) => existsSync(answerFile(hunt, run, entryId));
|
|
24
|
+
export function readAnswers(hunt) {
|
|
25
|
+
if (!existsSync(hunt))
|
|
26
|
+
return [];
|
|
27
|
+
const out = [];
|
|
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))
|
|
30
|
+
out.push(readJson(join(hunt, d, f)));
|
|
31
|
+
}
|
|
32
|
+
return out.sort((a, b) => a.run - b.run || cmp(a.entry_id, b.entry_id));
|
|
33
|
+
}
|
|
34
|
+
export const writePlan = (hunt, p) => writeAtomic(join(hunt, 'plan.json'), stableJson(p));
|
|
35
|
+
export const readPlan = (hunt) => (existsSync(join(hunt, 'plan.json')) ? readJson(join(hunt, 'plan.json')) : null);
|
|
36
|
+
export const writeResult = (hunt, r) => writeAtomic(join(hunt, 'result.json'), stableJson(r));
|
|
37
|
+
export const readResult = (hunt) => (existsSync(join(hunt, 'result.json')) ? readJson(join(hunt, 'result.json')) : null);
|
|
38
|
+
export const writeSynthesis = (hunt, s) => writeAtomic(join(hunt, 'synthesis.json'), stableJson(s));
|
|
39
|
+
export const readSynthesis = (hunt) => (existsSync(join(hunt, 'synthesis.json')) ? readJson(join(hunt, 'synthesis.json')) : null);
|
|
40
|
+
export const writeSkipped = (hunt, reason) => writeAtomic(join(hunt, 'skipped.json'), stableJson({ schema_version: '1.0.0', reason }));
|
|
41
|
+
export const readSkipped = (hunt) => (existsSync(join(hunt, 'skipped.json')) ? readJson(join(hunt, 'skipped.json')).reason : null);
|
|
42
|
+
export const hasHuntFiles = (hunt) => existsSync(hunt) && readdirSync(hunt).some((n) => /^run-\d+$/.test(n) || n === 'plan.json');
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export function summary(r) {
|
|
2
|
+
return [
|
|
3
|
+
`hullproof hunt: ${r.confirmed.length} confirmed, ${r.leads.length} leads not confirmed, ${r.hallucinations.count} hallucinations dropped, ${r.malformed.count} malformed`,
|
|
4
|
+
`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)`,
|
|
5
|
+
];
|
|
6
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { cmp } from '../util.js';
|
|
2
|
+
import { plain } from './lead.js';
|
|
3
|
+
export const SYNTHESIS_LIMITS = { summary: 4000, explanation: 800, explanations: 60 };
|
|
4
|
+
export function guardSynthesis(result, reply, meta) {
|
|
5
|
+
const known = new Set([...result.confirmed, ...result.leads].map((l) => l.key));
|
|
6
|
+
const o = reply && typeof reply === 'object' && !Array.isArray(reply) ? reply : {};
|
|
7
|
+
const summary = typeof o.summary === 'string' ? plain(o.summary, SYNTHESIS_LIMITS.summary) : '';
|
|
8
|
+
const seen = new Set();
|
|
9
|
+
const explanations = [];
|
|
10
|
+
if (Array.isArray(o.explanations)) {
|
|
11
|
+
for (const x of o.explanations) {
|
|
12
|
+
if (!x || typeof x !== 'object')
|
|
13
|
+
continue;
|
|
14
|
+
const { key, text } = x;
|
|
15
|
+
if (typeof key !== 'string' || typeof text !== 'string' || !known.has(key) || seen.has(key) || explanations.length >= SYNTHESIS_LIMITS.explanations)
|
|
16
|
+
continue;
|
|
17
|
+
seen.add(key);
|
|
18
|
+
explanations.push({ key, text: plain(text, SYNTHESIS_LIMITS.explanation) });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
explanations.sort((a, b) => cmp(a.key, b.key));
|
|
22
|
+
return { schema_version: '1.0.0', status: 'complete', stop: null, detail: null, provider: meta.provider, model: meta.model, summary, explanations, usage: meta.usage };
|
|
23
|
+
}
|
|
24
|
+
// A saved synthesis is applied again on replay against the result just computed: explanations for keys that no longer exist drop out.
|
|
25
|
+
export function reguard(result, s) {
|
|
26
|
+
const g = guardSynthesis(result, { summary: s.summary, explanations: s.explanations }, { provider: s.provider, model: s.model, usage: s.usage });
|
|
27
|
+
return { ...g, status: s.status, stop: s.stop, detail: s.detail };
|
|
28
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
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.js';
|
|
8
|
+
import { cmp } from '../util.js';
|
|
9
|
+
export const DETECTORS_DIR = fileURLToPath(new URL('../../detectors/', import.meta.url));
|
|
10
|
+
const CONF = new Set(['high', 'medium', 'low']);
|
|
11
|
+
function check(name, f) {
|
|
12
|
+
const bad = (msg) => { throw new Error(`detectors/${name}: ${msg}`); };
|
|
13
|
+
if (!STACK_CATEGORIES.includes(f.category))
|
|
14
|
+
bad(`unknown category ${f.category}`);
|
|
15
|
+
const ids = new Set();
|
|
16
|
+
for (const e of f.entries ?? bad('no entries')) {
|
|
17
|
+
if (!/^[a-z0-9][a-z0-9.-]*$/.test(e.id ?? ''))
|
|
18
|
+
bad(`bad id ${e.id}`);
|
|
19
|
+
if (ids.has(e.id))
|
|
20
|
+
bad(`duplicate id ${e.id}`);
|
|
21
|
+
ids.add(e.id);
|
|
22
|
+
if (!e.deps && !e.imports && !e.signals && !e.extensions)
|
|
23
|
+
bad(`${e.id} has no signal`);
|
|
24
|
+
const dc = typeof e.deps_confidence === 'object' ? Object.values(e.deps_confidence) : [e.deps_confidence];
|
|
25
|
+
for (const c of [...dc, e.imports_confidence, ...(e.signals ?? []).map((s) => s.confidence)]) {
|
|
26
|
+
if (c !== undefined && !CONF.has(c))
|
|
27
|
+
bad(`${e.id} bad confidence ${c}`);
|
|
28
|
+
}
|
|
29
|
+
for (const s of e.signals ?? []) {
|
|
30
|
+
if (!Array.isArray(s.files) || s.files.length === 0)
|
|
31
|
+
bad(`${e.id} signal without files`);
|
|
32
|
+
for (const r of [s.regex, ...(s.all ?? [])])
|
|
33
|
+
if (r !== undefined)
|
|
34
|
+
new RegExp(r); // throws on a bad pattern
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function loadDetectors(dir = DETECTORS_DIR) {
|
|
39
|
+
const names = readdirSync(dir).filter((n) => n.endsWith('.json')).sort(cmp);
|
|
40
|
+
const sums = [];
|
|
41
|
+
const files = [];
|
|
42
|
+
for (const n of names) {
|
|
43
|
+
const raw = readFileSync(join(dir, n));
|
|
44
|
+
sums.push(`${createHash('sha256').update(raw).digest('hex')} ${n}\n`);
|
|
45
|
+
const f = JSON.parse(raw.toString('utf8'));
|
|
46
|
+
check(n, f);
|
|
47
|
+
files.push(f);
|
|
48
|
+
}
|
|
49
|
+
return { files, sha256: createHash('sha256').update(sums.join('')).digest('hex') };
|
|
50
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
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 { STACK_CATEGORIES } from '../types/stack.js';
|
|
6
|
+
import { cmp, globToRegExp, sortUnique } from '../util.js';
|
|
7
|
+
import { ENGINE_VERSION } from '../version.js';
|
|
8
|
+
import { loadDetectors } from './detectors.js';
|
|
9
|
+
import { IMPORT_LANGS, MANIFEST_NAMES, extractDeps, extractImports, importLangOf, nameMatches } from './extract.js';
|
|
10
|
+
import { readHeadCommit } from './git.js';
|
|
11
|
+
import { walk } from './walk.js';
|
|
12
|
+
export const LIMITS = { maxFiles: 200_000, maxBytesPerFile: 512 * 1024, maxImportFiles: 50_000, evidencePerItem: 5, languageSamples: 3 };
|
|
13
|
+
// Separator for prefix matching of package names per ecosystem; '' means exact names only (use "x*" for a prefix).
|
|
14
|
+
const DEP_SEP = {
|
|
15
|
+
npm: '/', composer: '/', go: '/', maven: ':', nuget: '.', cocoapods: '/', swiftpm: '/',
|
|
16
|
+
pypi: '', cargo: '', rubygems: '', pub: '', hex: '', terraform: '',
|
|
17
|
+
};
|
|
18
|
+
// Extensions that are data, docs, media or build artefacts: neither a language nor worth listing as unknown.
|
|
19
|
+
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 ' +
|
|
20
|
+
'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 ' +
|
|
21
|
+
'woff woff2 ttf otf eot map snap example sample template tpl j2 properties plist pbxproj xcconfig xcscheme xcworkspacedata ' +
|
|
22
|
+
'storyboard xib entitlements strings pem crt cer key pub gitkeep keep sqlite sqlite3 db bin dat pkl pickle h5 npy ' +
|
|
23
|
+
'sum mod tfvars tfstate hcl lockb editorconfig env patch diff po pot mo xlsx xls docx doc pptx odt license gradle bat cmd ' +
|
|
24
|
+
'iml http rest bak orig tmp swp dockerignore gitignore gitattributes npmrc nvmrc prettierrc eslintrc babelrc ipynb').split(/\s+/));
|
|
25
|
+
const LOCKFILE = /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|composer\.lock|packages\.lock\.json|[^/]+\.lock)$/;
|
|
26
|
+
// Configuration files are looked for at the repository root and one level inside dot folders (.github, .do, .gitlab).
|
|
27
|
+
const CONFIG_EXT = /\.(ya?ml|toml|json|jsonc|ini|cfg|conf|hcl|properties)$/;
|
|
28
|
+
// Common tool configuration with no security meaning for discovery; not listed as unknown.
|
|
29
|
+
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;
|
|
30
|
+
const TF_UTILITY = new Set(['random', 'null', 'local', 'tls', 'time', 'archive', 'template', 'external', 'http', 'terraform', 'cloudinit']);
|
|
31
|
+
const depConf = (e, eco) => {
|
|
32
|
+
const c = e.deps_confidence;
|
|
33
|
+
return (typeof c === 'object' ? c[eco] ?? c['*'] : c) ?? 'high';
|
|
34
|
+
};
|
|
35
|
+
const RANK = { high: 0, medium: 1, low: 2 };
|
|
36
|
+
function readHead(abs, max) {
|
|
37
|
+
const fd = openSync(abs, 'r');
|
|
38
|
+
try {
|
|
39
|
+
const size = fstatSync(fd).size;
|
|
40
|
+
const buf = Buffer.alloc(Math.min(size, max));
|
|
41
|
+
const n = readSync(fd, buf, 0, buf.length, 0);
|
|
42
|
+
return { text: buf.subarray(0, n).toString('utf8'), truncated: size > max };
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
closeSync(fd);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const lineAt = (text, index) => {
|
|
49
|
+
let n = 1;
|
|
50
|
+
for (let i = 0; i < index; i++)
|
|
51
|
+
if (text.charCodeAt(i) === 10)
|
|
52
|
+
n++;
|
|
53
|
+
return n;
|
|
54
|
+
};
|
|
55
|
+
const extOf = (f) => {
|
|
56
|
+
const b = f.slice(f.lastIndexOf('/') + 1);
|
|
57
|
+
const d = b.lastIndexOf('.');
|
|
58
|
+
return d > 0 ? b.slice(d + 1).toLowerCase() : '';
|
|
59
|
+
};
|
|
60
|
+
export function discover(repo, opts = {}) {
|
|
61
|
+
const root = resolve(repo);
|
|
62
|
+
const det = opts.detectors ?? loadDetectors();
|
|
63
|
+
const listing = walk(root, LIMITS.maxFiles, opts.skipFiles);
|
|
64
|
+
const files = listing.files;
|
|
65
|
+
const cache = new Map();
|
|
66
|
+
const truncated = new Set();
|
|
67
|
+
const text = (f) => {
|
|
68
|
+
let t = cache.get(f);
|
|
69
|
+
if (t === undefined) {
|
|
70
|
+
try {
|
|
71
|
+
const r = readHead(join(root, f), LIMITS.maxBytesPerFile);
|
|
72
|
+
t = r.text;
|
|
73
|
+
if (r.truncated)
|
|
74
|
+
truncated.add(f);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
t = '';
|
|
78
|
+
}
|
|
79
|
+
cache.set(f, t);
|
|
80
|
+
}
|
|
81
|
+
return t;
|
|
82
|
+
};
|
|
83
|
+
// Facts.
|
|
84
|
+
const deps = [];
|
|
85
|
+
for (const f of files)
|
|
86
|
+
if (MANIFEST_NAMES.test(f))
|
|
87
|
+
deps.push(...extractDeps(f, text(f)));
|
|
88
|
+
const importFiles = files.filter((f) => importLangOf(f) !== null);
|
|
89
|
+
const scanned = importFiles.slice(0, LIMITS.maxImportFiles);
|
|
90
|
+
const imports = [];
|
|
91
|
+
for (const f of scanned) {
|
|
92
|
+
imports.push(...extractImports(f, text(f)));
|
|
93
|
+
cache.delete(f); // source text is not needed again unless a content signal asks for it
|
|
94
|
+
}
|
|
95
|
+
const depsByEco = Map.groupBy(deps, (d) => d.eco);
|
|
96
|
+
const importsByLang = Map.groupBy(imports, (i) => i.lang);
|
|
97
|
+
// Items.
|
|
98
|
+
const items = new Map();
|
|
99
|
+
const usedFiles = new Set();
|
|
100
|
+
const matchedTf = new Set();
|
|
101
|
+
const acc = (cat, id) => {
|
|
102
|
+
const k = `${cat}\u0000${id}`;
|
|
103
|
+
let a = items.get(k);
|
|
104
|
+
if (!a) {
|
|
105
|
+
a = { evidence: [], versions: new Set(), lists: new Map(), scalars: {} };
|
|
106
|
+
items.set(k, a);
|
|
107
|
+
}
|
|
108
|
+
return a;
|
|
109
|
+
};
|
|
110
|
+
const add = (a, ev) => { a.evidence.push(ev); usedFiles.add(ev.file); };
|
|
111
|
+
const addList = (a, attrs, file) => {
|
|
112
|
+
for (const [k, v] of Object.entries(attrs ?? {})) {
|
|
113
|
+
if (!a.lists.has(k))
|
|
114
|
+
a.lists.set(k, new Set());
|
|
115
|
+
a.lists.get(k).add(v === '$file' ? file : v);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
const globCache = new Map();
|
|
119
|
+
const filesFor = (globs) => {
|
|
120
|
+
const key = globs.join('\n');
|
|
121
|
+
let r = globCache.get(key);
|
|
122
|
+
if (!r) {
|
|
123
|
+
const res = globs.map(globToRegExp);
|
|
124
|
+
r = files.filter((f) => res.some((re) => re.test(f)));
|
|
125
|
+
globCache.set(key, r);
|
|
126
|
+
}
|
|
127
|
+
return r;
|
|
128
|
+
};
|
|
129
|
+
const runSignal = (cat, e, s, i) => {
|
|
130
|
+
const kind = s.regex ? 'content' : 'file';
|
|
131
|
+
const rule = `${cat}.${e.id}.${kind}.${i + 1}`;
|
|
132
|
+
const conf = s.confidence ?? (s.regex ? 'medium' : 'high');
|
|
133
|
+
const flags = 'm' + (s.ignore_case ? 'i' : '');
|
|
134
|
+
const re = s.regex ? new RegExp(s.regex, flags) : null;
|
|
135
|
+
const all = (s.all ?? []).map((r) => new RegExp(r, flags));
|
|
136
|
+
for (const f of filesFor(s.files)) {
|
|
137
|
+
if (re && LOCKFILE.test(f))
|
|
138
|
+
continue; // lockfiles are never templates or config; reading them only costs time
|
|
139
|
+
if (!re) {
|
|
140
|
+
const a = acc(cat, e.id);
|
|
141
|
+
add(a, { file: f, key: '(present)', rule, confidence: conf });
|
|
142
|
+
addList(a, s.attrs, f);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const t = text(f);
|
|
146
|
+
const m = re.exec(t);
|
|
147
|
+
if (!m || !all.every((r) => r.test(t)))
|
|
148
|
+
continue;
|
|
149
|
+
const a = acc(cat, e.id);
|
|
150
|
+
add(a, { file: f, line: lineAt(t, m.index), rule, confidence: conf });
|
|
151
|
+
addList(a, s.attrs, f);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
for (const df of det.files) {
|
|
155
|
+
const cat = df.category;
|
|
156
|
+
for (const e of df.entries) {
|
|
157
|
+
for (const [eco, names] of Object.entries(e.deps ?? {})) {
|
|
158
|
+
for (const d of depsByEco.get(eco) ?? []) {
|
|
159
|
+
if (!names.some((n) => nameMatches(d.name, n, DEP_SEP[eco] ?? '')))
|
|
160
|
+
continue;
|
|
161
|
+
if (eco === 'terraform')
|
|
162
|
+
matchedTf.add(d.name);
|
|
163
|
+
const a = acc(cat, e.id);
|
|
164
|
+
add(a, { file: d.file, ...(d.line !== undefined ? { line: d.line } : { key: d.key }), rule: `${cat}.${e.id}.dep.${eco}`, confidence: depConf(e, eco) });
|
|
165
|
+
if (d.version)
|
|
166
|
+
a.versions.add(`${d.name}@${d.version}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for (const [lang, mods] of Object.entries(e.imports ?? {})) {
|
|
170
|
+
const sep = IMPORT_LANGS[lang]?.sep ?? '';
|
|
171
|
+
for (const im of importsByLang.get(lang) ?? []) {
|
|
172
|
+
if (!mods.some((m) => nameMatches(im.module, m, sep)))
|
|
173
|
+
continue;
|
|
174
|
+
add(acc(cat, e.id), { file: im.file, line: im.line, rule: `${cat}.${e.id}.import.${lang}`, confidence: e.imports_confidence ?? 'medium' });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
(e.signals ?? []).forEach((s, i) => runSignal(cat, e, s, i));
|
|
178
|
+
if (e.extensions) {
|
|
179
|
+
const exts = new Set(e.extensions);
|
|
180
|
+
const hits = files.filter((f) => exts.has(extOf(f)));
|
|
181
|
+
if (hits.length) {
|
|
182
|
+
const a = acc(cat, e.id);
|
|
183
|
+
a.fileCount = hits.length;
|
|
184
|
+
for (const f of hits)
|
|
185
|
+
a.evidence.push({ file: f, key: '(extension)', rule: `${cat}.${e.id}.extension`, confidence: 'high' });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const a = items.get(`${cat}\u0000${e.id}`);
|
|
189
|
+
if (a)
|
|
190
|
+
Object.assign(a.scalars, e.attrs ?? {});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Unknowns: never guessed, only listed.
|
|
194
|
+
const unknown = [];
|
|
195
|
+
const langExts = new Set(det.files.filter((f) => f.category === 'languages').flatMap((f) => f.entries.flatMap((e) => e.extensions ?? [])));
|
|
196
|
+
const byExt = new Map();
|
|
197
|
+
for (const f of files) {
|
|
198
|
+
const x = extOf(f);
|
|
199
|
+
if (!x || langExts.has(x) || NOT_CODE.has(x))
|
|
200
|
+
continue;
|
|
201
|
+
if (!byExt.has(x))
|
|
202
|
+
byExt.set(x, []);
|
|
203
|
+
byExt.get(x).push(f);
|
|
204
|
+
}
|
|
205
|
+
for (const [x, fs] of byExt)
|
|
206
|
+
unknown.push({ file: fs[0], note: `no detector for the file extension .${x} (${fs.length} file${fs.length === 1 ? '' : 's'})` });
|
|
207
|
+
for (const f of files) {
|
|
208
|
+
const parts = f.split('/');
|
|
209
|
+
const configPlace = parts.length === 1 || (parts.length === 2 && parts[0].startsWith('.'));
|
|
210
|
+
if (!configPlace || !CONFIG_EXT.test(f) || usedFiles.has(f) || KNOWN_TOOLING.test(f))
|
|
211
|
+
continue;
|
|
212
|
+
unknown.push({ file: f, note: 'configuration file with no detector' });
|
|
213
|
+
}
|
|
214
|
+
const tfSeen = new Set();
|
|
215
|
+
for (const d of depsByEco.get('terraform') ?? []) {
|
|
216
|
+
if (matchedTf.has(d.name) || TF_UTILITY.has(d.name) || tfSeen.has(d.name))
|
|
217
|
+
continue;
|
|
218
|
+
tfSeen.add(d.name);
|
|
219
|
+
unknown.push({ file: d.file, note: `Terraform provider "${d.name}" has no detector` });
|
|
220
|
+
}
|
|
221
|
+
const LOCKS = ['package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb', 'bun.lock'];
|
|
222
|
+
const fileSet = new Set(files);
|
|
223
|
+
for (const f of files.filter((x) => x === 'package.json' || x.endsWith('/package.json'))) {
|
|
224
|
+
const dir = f.slice(0, -'package.json'.length);
|
|
225
|
+
if (LOCKS.some((l) => fileSet.has(dir + l) || fileSet.has(l)))
|
|
226
|
+
continue;
|
|
227
|
+
unknown.push({ file: f, note: 'package.json with no lockfile in its folder or the repository root: package manager not determined' });
|
|
228
|
+
}
|
|
229
|
+
// Assemble, sorted.
|
|
230
|
+
const out = Object.fromEntries(STACK_CATEGORIES.map((c) => [c, []]));
|
|
231
|
+
for (const [k, a] of items) {
|
|
232
|
+
const [cat, id] = k.split('\u0000');
|
|
233
|
+
const seen = new Set();
|
|
234
|
+
const ev = a.evidence
|
|
235
|
+
.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))
|
|
236
|
+
.filter((x) => { const s = `${x.file}|${x.line}|${x.key}|${x.rule}`; return seen.has(s) ? false : (seen.add(s), true); });
|
|
237
|
+
const shown = cat === 'languages' && a.fileCount ? LIMITS.languageSamples : LIMITS.evidencePerItem;
|
|
238
|
+
// The sample shows the best entry of each file first, so it spans files instead of repeating one file.
|
|
239
|
+
const fileSeen = new Set();
|
|
240
|
+
const firsts = ev.filter((x) => !fileSeen.has(x.file) && !!fileSeen.add(x.file));
|
|
241
|
+
const firstSet = new Set(firsts);
|
|
242
|
+
const sample = [...firsts, ...ev.filter((x) => !firstSet.has(x))].slice(0, shown);
|
|
243
|
+
const item = { id, confidence: ev[0].confidence, evidence: sample, evidence_count: ev.length, ...a.scalars };
|
|
244
|
+
if (a.versions.size)
|
|
245
|
+
item.versions = sortUnique(a.versions);
|
|
246
|
+
if (a.fileCount)
|
|
247
|
+
item.file_count = a.fileCount;
|
|
248
|
+
for (const [lk, set] of a.lists)
|
|
249
|
+
item[lk] = sortUnique(set);
|
|
250
|
+
out[cat].push(item);
|
|
251
|
+
}
|
|
252
|
+
for (const c of STACK_CATEGORIES)
|
|
253
|
+
out[c].sort((x, y) => cmp(x.id, y.id));
|
|
254
|
+
return {
|
|
255
|
+
schema_version: '1.0.0',
|
|
256
|
+
engine_version: ENGINE_VERSION,
|
|
257
|
+
detectors_sha256: det.sha256,
|
|
258
|
+
commit: readHeadCommit(root),
|
|
259
|
+
confirmed_by_user: false,
|
|
260
|
+
files: { listed: files.length, scanned_for_imports: scanned.length },
|
|
261
|
+
limits: {
|
|
262
|
+
max_files: LIMITS.maxFiles, max_bytes_per_file: LIMITS.maxBytesPerFile,
|
|
263
|
+
file_cap_reached: listing.capReached || importFiles.length > scanned.length,
|
|
264
|
+
truncated_files: sortUnique(truncated),
|
|
265
|
+
},
|
|
266
|
+
skipped: listing.skipped.slice().sort((x, y) => cmp(x.path, y.path)),
|
|
267
|
+
...out,
|
|
268
|
+
unknown: unknown.sort((x, y) => cmp(x.file, y.file) || cmp(x.note, y.note)),
|
|
269
|
+
};
|
|
270
|
+
}
|