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,98 @@
|
|
|
1
|
+
// The coverage table: one row per stack pack, from the levels the packs declare and the results recorded on their fixtures. Nothing
|
|
2
|
+
// here is typed by hand. The table is printed by `hullproof pack table`, shown in the README between two markers (a test keeps the two
|
|
3
|
+
// equal), and shown in the report for the stacks found in the repository. Plain words only: no dash punctuation.
|
|
4
|
+
import type { Stack } from '../types/index.ts';
|
|
5
|
+
import { cmp } from '../util.ts';
|
|
6
|
+
import { flawsFrom, LEVEL_NAMES, mappedFrom, rank, supportedLevel, topLevel, type FixtureResult, type Level, type Measurement, type PackManifest } from './manifest.ts';
|
|
7
|
+
import { defaultEnv, validateAll, type Checked, type Env } from './validate.ts';
|
|
8
|
+
|
|
9
|
+
export interface Row {
|
|
10
|
+
id: string; name: string; level: Level; scopes: { where: string; level: Level }[]; supported: Level;
|
|
11
|
+
onlySynthetic: boolean; limits: string[]; entry: string; flaws: string; falseFindings: string; stable: string; fixture: string; languages: string[]; frameworks: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const KIND: Record<Measurement['kind'], string> = { synthetic: 'synthetic app', demo: 'demo app', third_party: 'third party app' };
|
|
15
|
+
const of = (a: number, b: number): string => `${a} of ${b}`;
|
|
16
|
+
|
|
17
|
+
const kindText = (m: Measurement): string => `${m.kind === 'demo' ? 'demo app, counted over all its stacks' : KIND[m.kind]}${m.blind_key ? ' with a blind answer key' : ''}`;
|
|
18
|
+
|
|
19
|
+
export function rowOf(m: PackManifest, r: FixtureResult): Row {
|
|
20
|
+
const mp = mappedFrom(r);
|
|
21
|
+
const fl = flawsFrom(r);
|
|
22
|
+
const real = r.measurements.filter((x) => x.kind === 'third_party' && x.mapped);
|
|
23
|
+
const realText = real.length ? `route sample on ${real.length} real ${real.length === 1 ? 'app' : 'apps'}` : '';
|
|
24
|
+
const fixture = [fl ? `flaws on a ${kindText(fl)}` : '', mp && mp !== fl ? `map on a ${kindText(mp)}` : fl && !fl.blind_key ? 'no blind key' : '', realText].filter(Boolean).join(', ') || 'none';
|
|
25
|
+
return {
|
|
26
|
+
id: m.id, name: m.name, level: m.level, scopes: m.scopes ?? [], supported: supportedLevel(r, null),
|
|
27
|
+
entry: mp?.mapped ? `${of(mp.mapped.matched, mp.mapped.expected)} ${mp.mapped.what}` : 'not measured',
|
|
28
|
+
flaws: fl?.flaws ? of(fl.flaws.found, fl.flaws.planted) : 'not measured',
|
|
29
|
+
falseFindings: fl?.flaws ? String(fl.flaws.false_findings) : 'not measured',
|
|
30
|
+
stable: mp ? (mp.stability.identical ? `${mp.stability.runs} of ${mp.stability.runs} identical` : 'not identical') : 'not measured',
|
|
31
|
+
fixture,
|
|
32
|
+
onlySynthetic: r.measurements.every((x) => x.kind === 'synthetic'), limits: m.limits ?? [], languages: m.languages, frameworks: m.frameworks,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function coverageRows(checked: Checked[]): Row[] {
|
|
37
|
+
return checked.filter((c): c is Checked & { manifest: PackManifest; result: FixtureResult } => !!c.manifest && !!c.result)
|
|
38
|
+
.map((c) => rowOf(c.manifest, c.result))
|
|
39
|
+
.sort((a, b) => rank(b.level) - rank(a.level) || cmp(a.name, b.name));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const LEVEL_LEGEND: [Level, string][] = [
|
|
43
|
+
['L0', 'Secrets, dependencies, infrastructure and cloud configuration, and the generic rules. Runs on any stack.'],
|
|
44
|
+
['L1', 'The stack is recognised and its configuration files are checked.'],
|
|
45
|
+
['L2', 'Entry points and what protects them are written to the code map.'],
|
|
46
|
+
['L3', 'Executable checks and stack rules, measured on a fixture with a blind answer key.'],
|
|
47
|
+
['L4', 'A start recipe, so the proof sandbox can run the app and prove a flaw.'],
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const scopeText = (r: Row): string => (r.scopes.length ? r.scopes.map((s) => `${s.level} for ${s.where}`).join('; ') : 'none');
|
|
51
|
+
|
|
52
|
+
// The Markdown table with the measured figures. Everything in it comes from the rows, so it cannot say more than the recorded results support.
|
|
53
|
+
export function coverageMarkdown(rows: Row[]): string {
|
|
54
|
+
const head = '| Stack | Level | Higher level for | Mapped on the fixture | Planted flaws found | False findings | Fixture | Not covered |';
|
|
55
|
+
const sep = '|---|---|---|---|---|---|---|---|';
|
|
56
|
+
const body = rows.map((r) => `| ${r.name} | ${r.level} ${LEVEL_NAMES[r.level]} | ${scopeText(r)} | ${r.entry} | ${r.flaws} | ${r.falseFindings} | ${r.fixture} | ${r.limits.join(' ') || 'nothing recorded'} |`);
|
|
57
|
+
return [head, sep, ...body].join('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The README table states levels and what each was measured on, with no figures: the README makes no detection claim.
|
|
61
|
+
export function levelsMarkdown(rows: Row[]): string {
|
|
62
|
+
const head = '| Stack | Level | Higher level for | Measured on | Not covered |';
|
|
63
|
+
const sep = '|---|---|---|---|---|';
|
|
64
|
+
return [head, sep, ...rows.map((r) => `| ${r.name} | ${r.level} ${LEVEL_NAMES[r.level]} | ${scopeText(r)} | ${r.fixture} | ${r.limits.join(' ') || 'nothing recorded'} |`)].join('\n');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const legendMarkdown = (): string => ['| Level | What it means |', '|---|---|', ...LEVEL_LEGEND.map(([l, t]) => `| ${l} ${LEVEL_NAMES[l]} | ${t} |`)].join('\n');
|
|
68
|
+
|
|
69
|
+
export const README_START = '<!-- pack-coverage:start -->';
|
|
70
|
+
export const README_END = '<!-- pack-coverage:end -->';
|
|
71
|
+
|
|
72
|
+
// The block that goes between the README markers.
|
|
73
|
+
export function readmeBlock(rows: Row[]): string {
|
|
74
|
+
return `${README_START}\n${legendMarkdown()}\n\n${levelsMarkdown(rows)}\n${README_END}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function currentRows(env: Env = defaultEnv()): Row[] { return coverageRows(validateAll(undefined, env)); }
|
|
78
|
+
|
|
79
|
+
// Packs that apply to a repository, judged from stack.json: a pack applies when any of its frameworks or languages was found.
|
|
80
|
+
export function rowsForStack(stack: Stack, rows: Row[] = currentRows()): Row[] {
|
|
81
|
+
const ids = new Set<string>([...stack.languages, ...stack.frameworks, ...stack.mobile, ...stack.data_stores, ...stack.auth].map((i) => i.id));
|
|
82
|
+
return rows.filter((r) => r.frameworks.some((f) => ids.has(f)) || r.languages.some((l) => ids.has(l)));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The report section: levels for the stacks found here, and the plain statement that everything else is level 0.
|
|
86
|
+
export function reportSection(stack: Stack, rows: Row[] = currentRows()): string[] {
|
|
87
|
+
const hit = rowsForStack(stack, rows);
|
|
88
|
+
const out = ['## Stack coverage', '', 'Every stack gets the generic checks (level L0). The deeper levels below are what the engine has for the stacks found in this repository, as measured on its own fixtures. A level is a statement about depth, not about how many flaws exist in your app.', ''];
|
|
89
|
+
if (!hit.length) { out.push('No stack in this repository has a pack beyond L0. Secrets, dependencies, infrastructure files and the generic rules were checked.', ''); return out; }
|
|
90
|
+
out.push('| Stack | Level | Higher level for | Not covered |', '|---|---|---|---|', ...hit.map((r) => `| ${r.name} | ${r.level} ${LEVEL_NAMES[r.level]} | ${scopeText(r)} | ${r.limits.join(' ') || 'nothing recorded'} |`), '');
|
|
91
|
+
const synthetic = hit.filter((r) => r.onlySynthetic);
|
|
92
|
+
if (synthetic.length) out.push(`Measured on synthetic fixtures only: ${synthetic.map((r) => r.name).join(', ')}. No real application has been measured for them yet.`, '');
|
|
93
|
+
const sampled = hit.filter((r) => !r.onlySynthetic && /route sample on/.test(r.fixture));
|
|
94
|
+
if (sampled.length) out.push(`Also checked on real applications, by a sample of their route registrations only (planted flaws were not measured there): ${sampled.map((r) => r.name).join(', ')}.`, '');
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export { topLevel };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// The stack pack manifest (packs/<id>/pack.json) and the fixture result (packs/<id>/fixture-result.json): their types, the
|
|
2
|
+
// depth levels, and the one function that says which level a recorded result supports. Format: docs/PACK-GUIDE.md.
|
|
3
|
+
|
|
4
|
+
export const LEVELS = ['L0', 'L1', 'L2', 'L3', 'L4'] as const;
|
|
5
|
+
export type Level = typeof LEVELS[number];
|
|
6
|
+
export const rank = (l: Level): number => LEVELS.indexOf(l);
|
|
7
|
+
export const isLevel = (x: unknown): x is Level => typeof x === 'string' && (LEVELS as readonly string[]).includes(x);
|
|
8
|
+
|
|
9
|
+
export const LEVEL_NAMES: Record<Level, string> = { L0: 'Generic', L1: 'Detected', L2: 'Mapped', L3: 'Checked', L4: 'Provable' };
|
|
10
|
+
|
|
11
|
+
// Thresholds a recorded result must reach. Written once, here, and quoted in the guide.
|
|
12
|
+
export const L3_MIN_RECALL = 0.5; // share of planted flaws the pack's rules and checks must find, on a blind answer key
|
|
13
|
+
export const REAL_APP_MIN_RECALL = 0.9; // share of a sample of real route registrations the map must hold, on every real app measured for the pack
|
|
14
|
+
export const MIN_RUNS = 3; // identical runs needed to call the output stable
|
|
15
|
+
|
|
16
|
+
export interface FileRef { file: string; pro?: boolean }
|
|
17
|
+
export interface RuleRef { file: string; tests: string[]; pro?: boolean }
|
|
18
|
+
export interface CheckRef { id: string; pro?: boolean }
|
|
19
|
+
export interface RecipeRef { file: string; recipe: string; pro?: boolean }
|
|
20
|
+
|
|
21
|
+
export interface PackManifest {
|
|
22
|
+
schema: 1;
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
summary: string;
|
|
26
|
+
edition: 'free' | 'pro'; // where the pack's own files ship; a part marked pro is skipped when the Pro folders are absent
|
|
27
|
+
languages: string[]; // stack.json language ids
|
|
28
|
+
frameworks: string[]; // stack.json framework, mobile or data store ids the pack covers
|
|
29
|
+
level: Level; // what the pack declares for everything it lists
|
|
30
|
+
scopes?: { where: string; level: Level }[]; // narrower scopes that reach a higher level
|
|
31
|
+
limits?: string[]; // what the pack does not cover, in plain words; shown in the coverage table
|
|
32
|
+
parts: {
|
|
33
|
+
detectors?: string[]; // detector ids (detectors/*.json) the pack relies on
|
|
34
|
+
extractors?: FileRef[]; // entry point extractors and config readers
|
|
35
|
+
auth_patterns?: FileRef[]; // the files that name session, role and signature patterns
|
|
36
|
+
sources_sinks?: FileRef[]; // taint rule files (sources and sinks)
|
|
37
|
+
rules?: RuleRef[]; // Semgrep rule files, each with the fixture files that test them
|
|
38
|
+
checks?: CheckRef[]; // executable checks that apply to the stack
|
|
39
|
+
mapping?: FileRef[]; // where the checks and rules map to requirement IDs
|
|
40
|
+
start_recipe?: RecipeRef; // how the proof sandbox starts the app
|
|
41
|
+
};
|
|
42
|
+
fixture: {
|
|
43
|
+
dir: string; // the fixture app with planted flaws
|
|
44
|
+
fixed_dir: string; // the fixed copy
|
|
45
|
+
internal?: boolean; // the fixture is not in any edition, so its folders are checked only where they exist
|
|
46
|
+
answer_keys: ('in_repo' | 'outside_repo' | 'independent')[]; // where the keys are: in packs/<id>/answer-key.json, in ../bench-targets/answer-keys/<id>.json (local only), or an independent source
|
|
47
|
+
result: string; // packs/<id>/fixture-result.json
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// One measurement on one fixture. A pack can have several (a synthetic app and a demo app, for example).
|
|
52
|
+
export interface Measurement {
|
|
53
|
+
name: string; // what was measured: a folder or an app
|
|
54
|
+
kind: 'synthetic' | 'demo' | 'third_party';
|
|
55
|
+
blind_key: boolean; // the answer key was written apart from the extractor and kept out of the repository, or is independent
|
|
56
|
+
source: string; // how the numbers were made: a script, or the report they were recorded from
|
|
57
|
+
mapped: { what: string; expected: number; matched: number; extra: number } | null; // what the map holds for this stack: entry points, or tables and policies
|
|
58
|
+
flaws: { planted: number; found: number; false_findings: number } | null; // false findings: on the fixed copy, or judged by hand on the planted app when there is none
|
|
59
|
+
stability: { runs: number; identical: boolean };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface FixtureResult {
|
|
63
|
+
schema: 1;
|
|
64
|
+
pack: string;
|
|
65
|
+
measured: string; // YYYY-MM-DD
|
|
66
|
+
engine: string;
|
|
67
|
+
detect: { expected: string[]; found: string[] };
|
|
68
|
+
measurements: Measurement[];
|
|
69
|
+
proof: { scope: string; planted: number; proven: number; runs: number; false_proofs_fixed: number; identical: boolean; source: string }[];
|
|
70
|
+
notes: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const stable = (m: Measurement): boolean => m.stability.runs >= MIN_RUNS && m.stability.identical;
|
|
74
|
+
const exact = (m: Measurement): boolean => !!m.mapped && m.mapped.expected >= 1 && m.mapped.matched === m.mapped.expected && m.mapped.extra === 0 && stable(m);
|
|
75
|
+
// A real application is the hardest test of the extractor. A pack that fell short on one cannot claim L2, whatever the synthetic fixtures say.
|
|
76
|
+
const realAppShort = (m: Measurement): boolean => m.kind === 'third_party' && !!m.mapped && (m.mapped.expected < 1 || m.mapped.matched / m.mapped.expected < REAL_APP_MIN_RECALL || !stable(m));
|
|
77
|
+
const recallOk = (m: Measurement): boolean => !!m.flaws && m.blind_key && m.flaws.planted >= 1 && m.flaws.found / m.flaws.planted >= L3_MIN_RECALL && m.flaws.false_findings === 0 && stable(m);
|
|
78
|
+
|
|
79
|
+
// The highest level the result supports for a scope (null: the whole pack). Levels build on each other, so a gap stops the climb.
|
|
80
|
+
export function supportedLevel(r: FixtureResult, scope: string | null = null): Level {
|
|
81
|
+
const found = new Set(r.detect.found);
|
|
82
|
+
if (!r.detect.expected.length || !r.detect.expected.every((x) => found.has(x))) return 'L0';
|
|
83
|
+
if (!r.measurements.some(exact) || r.measurements.some(realAppShort)) return 'L1';
|
|
84
|
+
if (!r.measurements.some(recallOk)) return 'L2';
|
|
85
|
+
if (scope === null) return 'L3';
|
|
86
|
+
const pr = r.proof.find((p) => p.scope === scope);
|
|
87
|
+
return pr && pr.planted >= 1 && pr.proven === pr.planted && pr.false_proofs_fixed === 0 && pr.runs >= MIN_RUNS && pr.identical ? 'L4' : 'L3';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The measurements the coverage table shows: what the map holds comes from the best exact measurement, the flaws from the best flaw
|
|
91
|
+
// measurement (one with a blind key and a pass first). They can be two different fixtures; the table says so.
|
|
92
|
+
export const mappedFrom = (r: FixtureResult): Measurement | null => r.measurements.find((m) => exact(m) && m.blind_key && m.mapped && m.kind !== 'third_party') ?? r.measurements.find((m) => exact(m) && m.kind !== 'third_party') ?? r.measurements.find((m) => exact(m)) ?? r.measurements.find((m) => m.mapped) ?? null;
|
|
93
|
+
export const flawsFrom = (r: FixtureResult): Measurement | null => r.measurements.find(recallOk) ?? r.measurements.find((m) => m.flaws) ?? null;
|
|
94
|
+
|
|
95
|
+
// The level a manifest claims for the whole pack and for each scope, as [scope or null, level] pairs.
|
|
96
|
+
export const claims = (m: PackManifest): [string | null, Level][] => [[null, m.level], ...(m.scopes ?? []).map((s): [string, Level] => [s.where, s.level])];
|
|
97
|
+
export const topLevel = (m: PackManifest): Level => claims(m).map((c) => c[1]).reduce((a, b) => (rank(b) > rank(a) ? b : a));
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// hullproof pack validate: reads packs/<id>/pack.json and fixture-result.json and rejects a pack that is missing a part, lists a rule
|
|
2
|
+
// without a test, has no fixture result, uses a bad level, or claims a level its recorded fixture result does not support.
|
|
3
|
+
// Reads files only. Runs no scanner, no model and no network.
|
|
4
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { loadChecks } from '../checks/run.ts';
|
|
8
|
+
import { PRO_MAP_CHECKS } from '../checks/pro-map.ts';
|
|
9
|
+
import { DETECTORS_DIR } from '../discover/detectors.ts';
|
|
10
|
+
import { cmp } from '../util.ts';
|
|
11
|
+
import { claims, isLevel, LEVELS, rank, supportedLevel, topLevel, type FileRef, type FixtureResult, type Level, type PackManifest } from './manifest.ts';
|
|
12
|
+
|
|
13
|
+
export const ENGINE_ROOT = fileURLToPath(new URL('../../', import.meta.url));
|
|
14
|
+
export const PACKS_DIR = join(ENGINE_ROOT, 'packs');
|
|
15
|
+
|
|
16
|
+
export interface Issue { pack: string; code: string; message: string }
|
|
17
|
+
export interface Checked { dir: string; manifest: PackManifest | null; result: FixtureResult | null; issues: Issue[]; skipped: string[] }
|
|
18
|
+
|
|
19
|
+
// What a part must have at each level. L0 needs nothing but the manifest and a fixture result; every pack needs the fixture result.
|
|
20
|
+
const NEEDS: Record<string, Level> = { detectors: 'L1', extractors: 'L2', auth_patterns: 'L2', checks: 'L3', mapping: 'L3', start_recipe: 'L4' };
|
|
21
|
+
|
|
22
|
+
export interface Env {
|
|
23
|
+
root: string;
|
|
24
|
+
detectorIds: Set<string>;
|
|
25
|
+
checkIds: Set<string>;
|
|
26
|
+
recipes: Set<string>;
|
|
27
|
+
hasPro: boolean; // this is the Pro edition: the Pro only checks are loaded
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function defaultEnv(root: string = ENGINE_ROOT): Env {
|
|
31
|
+
const detectorIds = new Set<string>();
|
|
32
|
+
for (const n of readdirSync(DETECTORS_DIR).filter((f) => f.endsWith('.json'))) {
|
|
33
|
+
for (const e of (JSON.parse(readFileSync(join(DETECTORS_DIR, n), 'utf8')) as { entries: { id: string }[] }).entries) detectorIds.add(e.id);
|
|
34
|
+
}
|
|
35
|
+
return { root, detectorIds, checkIds: new Set(loadChecks().map((c) => c.id)), recipes: RECIPES, hasPro: PRO_MAP_CHECKS.length > 0 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Start recipes the proof sandbox builds (src/prove/recipe.ts). A pack at L4 names one of these.
|
|
39
|
+
export const RECIPES = new Set(['node-express', 'python-flask']);
|
|
40
|
+
|
|
41
|
+
const SEC_ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
42
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
43
|
+
|
|
44
|
+
export function readJson<T>(file: string): T | string {
|
|
45
|
+
try { return JSON.parse(readFileSync(file, 'utf8')) as T; } catch (e) { return (e as Error).message; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The Semgrep rule ids in a rule file, read without YAML parsing: lines of the form " - id: name".
|
|
49
|
+
export const ruleIds = (text: string): string[] => [...text.matchAll(/^\s*-\s+id:\s*([A-Za-z0-9._-]+)\s*$/gm)].map((m) => m[1]);
|
|
50
|
+
|
|
51
|
+
export function validatePack(folder: string, env: Env): Checked {
|
|
52
|
+
const id = folder.split('/').filter(Boolean).pop() ?? folder;
|
|
53
|
+
const issues: Issue[] = [];
|
|
54
|
+
const skipped: string[] = [];
|
|
55
|
+
const bad = (code: string, message: string): void => { issues.push({ pack: id, code, message }); };
|
|
56
|
+
const out = (manifest: PackManifest | null, result: FixtureResult | null): Checked => ({ dir: folder, manifest, result, issues: issues.sort((a, b) => cmp(a.code, b.code) || cmp(a.message, b.message)), skipped });
|
|
57
|
+
|
|
58
|
+
const mf = join(folder, 'pack.json');
|
|
59
|
+
if (!existsSync(mf)) { bad('manifest_missing', 'there is no pack.json'); return out(null, null); }
|
|
60
|
+
const raw = readJson<PackManifest>(mf);
|
|
61
|
+
if (typeof raw === 'string') { bad('manifest_unreadable', `pack.json is not valid JSON: ${raw}`); return out(null, null); }
|
|
62
|
+
const m = raw;
|
|
63
|
+
|
|
64
|
+
// ---- identity and levels
|
|
65
|
+
if (m.schema !== 1) bad('schema', 'schema must be 1');
|
|
66
|
+
if (typeof m.id !== 'string' || !SEC_ID.test(m.id)) bad('id', 'id must be lower case words joined with hyphens');
|
|
67
|
+
else if (m.id !== id) bad('id', `id "${m.id}" does not match the folder name "${id}"`);
|
|
68
|
+
for (const k of ['name', 'summary'] as const) if (typeof m[k] !== 'string' || !m[k].trim()) bad('field', `${k} is missing`);
|
|
69
|
+
if (m.edition !== 'free' && m.edition !== 'pro') bad('field', 'edition must be free or pro');
|
|
70
|
+
for (const k of ['languages', 'frameworks'] as const) if (!Array.isArray(m[k]) || m[k].length === 0 || m[k].some((x) => typeof x !== 'string')) bad('field', `${k} must list at least one stack id`);
|
|
71
|
+
if (!isLevel(m.level)) bad('level', `level "${String(m.level)}" is not one of ${LEVELS.join(', ')}`);
|
|
72
|
+
for (const s of m.scopes ?? []) {
|
|
73
|
+
if (typeof s.where !== 'string' || !s.where.trim()) bad('level', 'a scope has no "where"');
|
|
74
|
+
else if (!isLevel(s.level)) bad('level', `scope "${s.where}" has level "${String(s.level)}", which is not one of ${LEVELS.join(', ')}`);
|
|
75
|
+
else if (isLevel(m.level) && rank(s.level) <= rank(m.level)) bad('level', `scope "${s.where}" is L${rank(s.level)}, which is not higher than the pack level; a scope exists only to reach a higher level`);
|
|
76
|
+
}
|
|
77
|
+
if (m.limits !== undefined && (!Array.isArray(m.limits) || m.limits.some((x) => typeof x !== 'string' || !x.trim()))) bad('field', 'limits must be a list of sentences');
|
|
78
|
+
const levelsOk = isLevel(m.level) && (m.scopes ?? []).every((s) => isLevel(s.level));
|
|
79
|
+
const parts = m.parts ?? {};
|
|
80
|
+
if (!m.parts || typeof m.parts !== 'object') bad('parts_missing', 'parts is missing');
|
|
81
|
+
|
|
82
|
+
// ---- every part the declared level needs must be there
|
|
83
|
+
if (levelsOk) {
|
|
84
|
+
const top = topLevel(m);
|
|
85
|
+
for (const [part, at] of Object.entries(NEEDS)) {
|
|
86
|
+
const have = (parts as Record<string, unknown>)[part];
|
|
87
|
+
const present = Array.isArray(have) ? have.length > 0 : have !== undefined && have !== null;
|
|
88
|
+
if (rank(top) >= rank(at) && !present) bad('part_missing', `the pack declares ${top}, which needs "${part}" (from ${at}), and the manifest has none`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---- files named by a part must exist (a Pro part is skipped when the Pro folders are not in this tree)
|
|
93
|
+
const here = (rel: string): boolean => existsSync(join(env.root, rel));
|
|
94
|
+
const fileRef = (part: string, r: FileRef): void => {
|
|
95
|
+
if (!r || typeof r.file !== 'string') { bad('file', `${part}: an entry has no file`); return; }
|
|
96
|
+
if (here(r.file)) return;
|
|
97
|
+
if (r.pro && !env.hasPro) { skipped.push(`${part}: ${r.file} (Pro, not in this edition)`); return; }
|
|
98
|
+
bad('file_missing', `${part}: ${r.file} does not exist`);
|
|
99
|
+
};
|
|
100
|
+
for (const part of ['extractors', 'auth_patterns', 'sources_sinks', 'mapping'] as const) for (const r of parts[part] ?? []) fileRef(part, r);
|
|
101
|
+
|
|
102
|
+
for (const d of parts.detectors ?? []) if (!env.detectorIds.has(d)) bad('detector_unknown', `detectors: "${d}" is not an id in detectors/*.json`);
|
|
103
|
+
for (const f of m.frameworks ?? []) if (!env.detectorIds.has(f)) bad('detector_unknown', `frameworks: "${f}" is not an id in detectors/*.json`);
|
|
104
|
+
|
|
105
|
+
// ---- rules need tests: every rule id in the file has a "ruleid" line and an "ok" line in the test files
|
|
106
|
+
for (const r of parts.rules ?? []) {
|
|
107
|
+
if (!r || typeof r.file !== 'string') { bad('file', 'rules: an entry has no file'); continue; }
|
|
108
|
+
if (!here(r.file)) { if (r.pro && !env.hasPro) skipped.push(`rules: ${r.file} (Pro, not in this edition)`); else bad('file_missing', `rules: ${r.file} does not exist`); continue; }
|
|
109
|
+
if (!Array.isArray(r.tests) || r.tests.length === 0) { bad('rule_without_test', `rules: ${r.file} has no test files`); continue; }
|
|
110
|
+
// An npm install carries the rules but not the test folder, so the rule tests cannot be read there.
|
|
111
|
+
if (!existsSync(join(env.root, 'test/fixtures'))) { skipped.push(`rules: tests for ${r.file} (no fixture folder in this tree)`); continue; }
|
|
112
|
+
const missing = r.tests.filter((t) => !here(t));
|
|
113
|
+
for (const t of missing) bad('file_missing', `rules: test file ${t} for ${r.file} does not exist`);
|
|
114
|
+
if (missing.length) continue;
|
|
115
|
+
const testText = r.tests.map((t) => readFileSync(join(env.root, t), 'utf8')).join('\n');
|
|
116
|
+
for (const rid of ruleIds(readFileSync(join(env.root, r.file), 'utf8'))) {
|
|
117
|
+
if (!new RegExp(`ruleid: ${rid}\\b`).test(testText)) bad('rule_without_test', `rules: ${rid} in ${r.file} has no "ruleid: ${rid}" line in its test files`);
|
|
118
|
+
if (!new RegExp(`ok: ${rid}\\b`).test(testText)) bad('rule_without_test', `rules: ${rid} in ${r.file} has no "ok: ${rid}" line in its test files`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---- checks must exist in this engine (a Pro check is skipped when the Pro folders are absent)
|
|
123
|
+
for (const c of parts.checks ?? []) {
|
|
124
|
+
if (!c || typeof c.id !== 'string') { bad('check_unknown', 'checks: an entry has no id'); continue; }
|
|
125
|
+
if (env.checkIds.has(c.id)) continue;
|
|
126
|
+
if (c.pro && !env.hasPro) skipped.push(`checks: ${c.id} (Pro, not in this edition)`); else bad('check_unknown', `checks: ${c.id} is not a check this engine loads`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---- start recipe
|
|
130
|
+
const rc = parts.start_recipe;
|
|
131
|
+
if (rc) {
|
|
132
|
+
if (!env.recipes.has(rc.recipe)) bad('recipe_unknown', `start_recipe: "${rc.recipe}" is not a recipe the proof sandbox builds (${[...env.recipes].sort(cmp).join(', ')})`);
|
|
133
|
+
fileRef('start_recipe', rc);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---- fixture and its recorded result
|
|
137
|
+
const fx = m.fixture;
|
|
138
|
+
let result: FixtureResult | null = null;
|
|
139
|
+
if (!fx || typeof fx !== 'object') bad('fixture_missing', 'fixture is missing');
|
|
140
|
+
else {
|
|
141
|
+
for (const k of ['dir', 'fixed_dir', 'result'] as const) if (typeof fx[k] !== 'string' || !fx[k]) bad('fixture_missing', `fixture.${k} is missing`);
|
|
142
|
+
if (!Array.isArray(fx.answer_keys) || fx.answer_keys.length === 0 || fx.answer_keys.some((k) => !['in_repo', 'outside_repo', 'independent'].includes(k))) bad('fixture_missing', 'fixture.answer_keys must list in_repo, outside_repo or independent');
|
|
143
|
+
// Fixtures marked internal are in neither edition. Their folders are checked only in a tree that has the internal fixture area.
|
|
144
|
+
const internalArea = here('test/fixtures/packs');
|
|
145
|
+
for (const k of ['dir', 'fixed_dir'] as const) if (typeof fx[k] === 'string' && fx[k] && !here(fx[k])) {
|
|
146
|
+
if (fx.internal && !internalArea) skipped.push(`fixture.${k}: ${fx[k]} (internal fixture, not in this edition)`);
|
|
147
|
+
else if (fx.internal !== true && !existsSync(join(env.root, 'test/fixtures'))) skipped.push(`fixture.${k}: ${fx[k]} (no fixture folder in this tree)`);
|
|
148
|
+
else bad('fixture_missing', `fixture.${k}: ${fx[k]} does not exist`);
|
|
149
|
+
}
|
|
150
|
+
const rf = typeof fx.result === 'string' ? join(env.root, fx.result) : '';
|
|
151
|
+
if (!rf || !existsSync(rf)) bad('fixture_result_missing', `there is no fixture result (${fx.result ?? 'no path'}); a pack with no recorded result on its fixture is not listed`);
|
|
152
|
+
else {
|
|
153
|
+
const r = readJson<FixtureResult>(rf);
|
|
154
|
+
if (typeof r === 'string') bad('fixture_result_invalid', `${fx.result} is not valid JSON: ${r}`);
|
|
155
|
+
else {
|
|
156
|
+
const why = checkResult(r, m.id);
|
|
157
|
+
if (why.length) for (const w of why) bad('fixture_result_invalid', `${fx.result}: ${w}`);
|
|
158
|
+
else result = r;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---- the declared levels against what the result supports
|
|
164
|
+
if (levelsOk && result) {
|
|
165
|
+
for (const [scope, lv] of claims(m)) {
|
|
166
|
+
const sup = supportedLevel(result, scope);
|
|
167
|
+
if (rank(lv) > rank(sup)) bad('level_too_high', `${scope ? `scope "${scope}"` : 'the pack'} declares ${lv}, and the recorded fixture result supports ${sup}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return out(m, result);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function checkResult(r: FixtureResult, id: string): string[] {
|
|
174
|
+
const w: string[] = [];
|
|
175
|
+
if (r.schema !== 1) w.push('schema must be 1');
|
|
176
|
+
if (r.pack !== id) w.push(`pack is "${String(r.pack)}", not "${id}"`);
|
|
177
|
+
if (typeof r.measured !== 'string' || !DATE.test(r.measured)) w.push('measured must be a date, YYYY-MM-DD');
|
|
178
|
+
if (!r.detect || !Array.isArray(r.detect.expected) || !Array.isArray(r.detect.found)) w.push('detect.expected and detect.found are required');
|
|
179
|
+
if (!Array.isArray(r.measurements) || r.measurements.length === 0) w.push('measurements is empty; a pack with no measurement on a fixture is not listed');
|
|
180
|
+
const nums = (o: object | null, keys: string[], name: string): void => { if (o && keys.some((k) => !Number.isInteger((o as Record<string, unknown>)[k]) || (o as Record<string, number>)[k] < 0)) w.push(`${name} needs whole numbers for ${keys.join(', ')}`); };
|
|
181
|
+
for (const m of Array.isArray(r.measurements) ? r.measurements : []) {
|
|
182
|
+
const n = `measurement "${String(m?.name)}"`;
|
|
183
|
+
if (!m || typeof m.name !== 'string' || !['synthetic', 'demo', 'third_party'].includes(m.kind) || typeof m.blind_key !== 'boolean') { w.push(`${n} needs name, kind and blind_key`); continue; }
|
|
184
|
+
if (typeof m.source !== 'string' || !m.source.trim()) w.push(`${n}: source says nothing about how the numbers were made`);
|
|
185
|
+
nums(m.mapped, ['expected', 'matched', 'extra'], `${n} mapped`);
|
|
186
|
+
if (m.mapped && (typeof m.mapped.what !== 'string' || !m.mapped.what)) w.push(`${n}: mapped.what is missing`);
|
|
187
|
+
nums(m.flaws, ['planted', 'found', 'false_findings'], `${n} flaws`);
|
|
188
|
+
if (!m.stability || !Number.isInteger(m.stability.runs) || typeof m.stability.identical !== 'boolean') w.push(`${n}: stability.runs and stability.identical are required`);
|
|
189
|
+
if (m.mapped && m.mapped.matched > m.mapped.expected) w.push(`${n}: matched is more than expected`);
|
|
190
|
+
if (m.flaws && m.flaws.found > m.flaws.planted) w.push(`${n}: flaws.found is more than planted`);
|
|
191
|
+
}
|
|
192
|
+
if (!Array.isArray(r.proof)) w.push('proof must be a list (empty when no proof was run)');
|
|
193
|
+
else for (const p of r.proof) nums(p, ['planted', 'proven', 'runs', 'false_proofs_fixed'], 'proof');
|
|
194
|
+
return w;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function listPacks(dir: string = PACKS_DIR): string[] {
|
|
198
|
+
if (!existsSync(dir)) return [];
|
|
199
|
+
return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => join(dir, d.name)).sort(cmp);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function validateAll(dir: string = PACKS_DIR, env: Env = defaultEnv()): Checked[] {
|
|
203
|
+
return listPacks(dir).map((p) => validatePack(p, env));
|
|
204
|
+
}
|