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,160 @@
|
|
|
1
|
+
// report.md: the plain English report for people who build with AI tools. Built from the scan folder only.
|
|
2
|
+
// Same scan folder, same bytes: no clock, no absolute path, everything sorted.
|
|
3
|
+
import { cmp } from '../util.ts';
|
|
4
|
+
import { ENGINE_VERSION } from '../version.ts';
|
|
5
|
+
import type { FailRecord, ObservationRecord, RouteState } from '../types/index.ts';
|
|
6
|
+
import { OWNER_ACTION } from './build.ts';
|
|
7
|
+
import type { GateOutcome } from '../gate/gate.ts';
|
|
8
|
+
import { SEV_RANK, type Kit } from './kit.ts';
|
|
9
|
+
import type { ScanFolder } from './load.ts';
|
|
10
|
+
import { renderEntryPoints } from './entry-points.ts';
|
|
11
|
+
import { reportSection } from '../packs/coverage.ts';
|
|
12
|
+
import { renderHunt } from './hunt-section.ts';
|
|
13
|
+
import { renderProve } from './prove-section.ts';
|
|
14
|
+
import type { Merged } from '../confirm/merge.ts';
|
|
15
|
+
|
|
16
|
+
const cell = (s: string): string => s.replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
|
17
|
+
const loc = (e: { file: string; line: number }): string => `\`${e.file}:${e.line}\``;
|
|
18
|
+
const STATE_ORDER: RouteState[] = ['NEEDS DYNAMIC TEST', 'NEEDS DASHBOARD', 'NEEDS BUILD', 'ASK OWNER', 'ATTESTATION', 'UNKNOWN'];
|
|
19
|
+
const STATE_MEANS: Record<RouteState, string> = {
|
|
20
|
+
'NEEDS DYNAMIC TEST': 'Only a test of the running app (or a staging copy) can settle these.',
|
|
21
|
+
'NEEDS DASHBOARD': 'Only a provider setting can settle these, shown by a dated export or screenshot.',
|
|
22
|
+
'NEEDS BUILD': 'Only a production build from this commit can settle these.',
|
|
23
|
+
'ASK OWNER': 'Only the owner can answer these.',
|
|
24
|
+
'ATTESTATION': 'These need a signed, dated statement.',
|
|
25
|
+
'UNKNOWN': 'These are settled by reading code, and no engine check or rule covers them yet. The engine has a code map and no model.',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function renderReport(s: ScanFolder, kit: Kit, gate: GateOutcome, merged: Merged | null = null): string {
|
|
29
|
+
const out: string[] = [];
|
|
30
|
+
const p = (...l: string[]): void => { out.push(...l); };
|
|
31
|
+
const fails = s.findings.records.filter((r): r is FailRecord => r.kind === 'fail')
|
|
32
|
+
.sort((a, b) => SEV_RANK[b.severity] - SEV_RANK[a.severity] || cmp(a.fingerprint, b.fingerprint));
|
|
33
|
+
const allObs = s.findings.records.filter((r): r is ObservationRecord => r.kind === 'observation');
|
|
34
|
+
// Test code observations carry a fingerprint slug starting with test-code (src/report/build.ts).
|
|
35
|
+
const slugOf = (o: ObservationRecord): string => o.fingerprint.split(':')[2];
|
|
36
|
+
const isTest = (o: ObservationRecord): boolean => slugOf(o).startsWith('test-code-');
|
|
37
|
+
const isLead = (o: ObservationRecord): boolean => slugOf(o).startsWith('lead-');
|
|
38
|
+
const obs = allObs.filter((o) => !isTest(o) && !isLead(o));
|
|
39
|
+
const testObs = allObs.filter(isTest);
|
|
40
|
+
const leadObs = allObs.filter(isLead);
|
|
41
|
+
const count = (w: string): number => s.results.filter((r) => r.result.startsWith(w)).length;
|
|
42
|
+
const stageText = s.info.stage_source === 'STAGE.md' ? 'read from docs/security/STAGE.md' : s.info.stage_source === 'flag' ? 'set with --stage' : 'not declared, so LAUNCH is assumed';
|
|
43
|
+
|
|
44
|
+
p('# Hullproof report', '');
|
|
45
|
+
p(`Commit \`${s.info.commit}\`, tree \`${s.info.tree}\`, ${s.info.tree_clean ? 'no uncommitted changes to tracked files' : 'with uncommitted changes to tracked files (the report covers the files as they were, not the commit alone)'}. Hullproof Engine ${ENGINE_VERSION}, standard ${s.info.kit.standard_version}, ${s.info.edition} edition, stage ${s.info.stage} (${stageText}).`, '');
|
|
46
|
+
p('This report comes from code and configuration only, with no model. Every finding is a pattern match that nobody has confirmed yet, so treat each one as likely and use its check step to confirm it. A requirement that nothing settled is listed as NOT ASSESSED. That is never a pass.', '');
|
|
47
|
+
|
|
48
|
+
p(`## Verdict: ${gate.label}`, '');
|
|
49
|
+
const notMet = gate.conditions.filter((c) => !c.met);
|
|
50
|
+
if (gate.verdict === 'NOT READY') {
|
|
51
|
+
p(`The release gate is not met. ${notMet.length} of ${gate.conditions.length} conditions are open${gate.runSupportsReady ? '' : ', and a run with unconfirmed BLOCKER or CRITICAL findings cannot support READY'}. A scan of files alone can never reach READY: most BLOCKER and CRITICAL requirements are settled by provider settings, a production build or a test of the running app, which the owner supplies.`, '');
|
|
52
|
+
} else {
|
|
53
|
+
p('Every condition of the release gate is met for this commit with the evidence supplied.', '');
|
|
54
|
+
}
|
|
55
|
+
p('| Condition | Met | Why |', '|---|---|---|');
|
|
56
|
+
for (const c of gate.conditions) p(`| ${c.id} | ${c.met ? 'yes' : 'no'} | ${cell(c.why)} |`);
|
|
57
|
+
p('');
|
|
58
|
+
|
|
59
|
+
p('## Summary', '');
|
|
60
|
+
const bySev = ['BLOCKER', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW'].map((v) => [v, fails.filter((f) => f.severity === v).length] as const).filter(([, n]) => n > 0);
|
|
61
|
+
p(`1. Findings: ${fails.length}${bySev.length ? ` (${bySev.map(([v, n]) => `${n} ${v}`).join(', ')})` : ''}. In test code, reported apart and never in the gate: ${testObs.length}. Leads from rules still being measured, never in the gate: ${leadObs.length}.`);
|
|
62
|
+
p(`2. Requirements in scope: ${s.results.length}. FAIL ${count('FAIL')}, PASS (static) ${count('PASS')}, NOT ASSESSED ${count('NOT ASSESSED')}.`);
|
|
63
|
+
p(`3. Checks: ${s.info.checks.ran} ran, ${s.info.checks.not_run} did not run (listed below with the reason).`);
|
|
64
|
+
const ran = s.metas.filter((m) => m.status === 'ran').map((m) => m.tool);
|
|
65
|
+
p(`4. Scanners that ran: ${ran.length ? ran.join(', ') : 'none'}.${s.info.scanners_available ? '' : ' The scanner adapters were not available in this build, so no scanner ran.'}`);
|
|
66
|
+
const pack = s.info.rule_pack;
|
|
67
|
+
p(`5. Semgrep rule pack: ${pack ? `${pack.name}, ${pack.files.length} rule files added to the engine rules (sha256 ${pack.sha256.slice(0, 16)})` : 'none, the engine rules only'}.`, '');
|
|
68
|
+
|
|
69
|
+
p('## Findings, most severe first', '');
|
|
70
|
+
if (fails.length === 0) p('No check or mapped rule failed. That does not mean the requirements pass: see NOT ASSESSED below.', '');
|
|
71
|
+
fails.forEach((f, i) => {
|
|
72
|
+
const me = f.machine_evidence!;
|
|
73
|
+
p(`### F-${i + 1} ${f.severity}: ${f.title}`, '');
|
|
74
|
+
p(`Where: ${f.evidence.map(loc).join(', ')}`, '');
|
|
75
|
+
p(`Requirements: ${f.sec_ids.map((id) => `${id} (${kit.byId.get(id)?.title ?? 'unknown'})`).join('; ')}`, '');
|
|
76
|
+
p(`What could happen: ${f.impact_statement}`, '');
|
|
77
|
+
p(`How to fix it: ${f.remediation.strategy}`, '');
|
|
78
|
+
p(`How to check the fix: ${f.remediation.confirm}`, '');
|
|
79
|
+
p(`Found by: ${me.tool === 'hullproof-check' ? `engine check ${me.rule_id}` : `${me.tool} ${me.tool_version}, rule ${me.rule_id}`}. Each line: ${f.evidence.map((e) => `${e.file}:${e.line} ${e.note}`).join('; ')}.`, '');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
p(...renderHunt(s.hunt, merged));
|
|
83
|
+
p(...renderProve(s.prove, merged));
|
|
84
|
+
|
|
85
|
+
if (obs.length) {
|
|
86
|
+
p('## Leads outside this run\'s scope', '');
|
|
87
|
+
p('These were found but do not count in this run: their requirement is above the declared stage, outside the edition, or not mapped to a requirement. They never affect the gate.', '');
|
|
88
|
+
for (const o of obs) p(`- ${o.title} at ${o.where.map(loc).join(', ')}. ${o.note}`);
|
|
89
|
+
p('');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (leadObs.length) {
|
|
93
|
+
p('## Leads from rules still being measured', '');
|
|
94
|
+
p('Each rule has a confidence tier from measurement on public benchmarks (rules/tiers.json). A rule whose measured precision is below the threshold, or that has not been measured yet, reports leads: read the code at each place. Leads never fail a requirement or affect the gate. Secret findings are never leads.', '');
|
|
95
|
+
for (const o of leadObs) p(`- ${o.title} at ${o.where.map(loc).join(', ')}. ${o.note}`);
|
|
96
|
+
p('');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (testObs.length) {
|
|
100
|
+
p('## Findings in test code', '');
|
|
101
|
+
p('These are in test, fixture, spec, mock or example files, found by folder and file name conventions. They are kept so a person can read them, and they never fail a requirement or affect the gate. Secret findings are not in this list: a live key is a leak wherever it is committed, so they stay findings.', '');
|
|
102
|
+
for (const o of testObs) p(`- ${o.title} at ${o.where.map(loc).join(', ')}. ${o.note}`);
|
|
103
|
+
p('');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
p('## What could not run, and why', '');
|
|
107
|
+
p('| Scanner | Status | Why | Version | Requirements left NOT ASSESSED because of it |', '|---|---|---|---|---|');
|
|
108
|
+
if (s.metas.length === 0) p('| all | no scanner adapter ran | | none | the requirements their rules cover |');
|
|
109
|
+
for (const m of s.metas) p(`| ${m.tool} | ${m.status} | ${cell(s.reasons[m.tool] ?? '')} | ${m.version ?? 'none'}${m.version && !m.version_tested ? ' (not a tested version)' : ''} | ${m.not_assessed_sec_ids.join(', ') || 'none'} |`);
|
|
110
|
+
p('');
|
|
111
|
+
if (s.checks.not_run.length) {
|
|
112
|
+
p('| Check | Why it did not run |', '|---|---|');
|
|
113
|
+
for (const n of s.checks.not_run) p(`| ${n.check_id} | ${cell(n.reason)} |`);
|
|
114
|
+
p('');
|
|
115
|
+
}
|
|
116
|
+
if (s.info.dropped.length) {
|
|
117
|
+
p('Some checks name requirements that this kit\'s controls file does not hold yet, so those requirements are not in the results:', '');
|
|
118
|
+
for (const d of s.info.dropped) p(`- ${d.source} at \`${d.where}\`: ${d.ids.join(', ')}`);
|
|
119
|
+
p('');
|
|
120
|
+
}
|
|
121
|
+
p('Discovery reads files, not deployments. Anything set only in a provider dashboard is invisible to this scan.', '');
|
|
122
|
+
|
|
123
|
+
p('## What the scan read', '');
|
|
124
|
+
p(`${s.surface.files_listed} files were listed (dependency folders, build output, ignored files and the kit's own folders are left out).`, '');
|
|
125
|
+
p('| Kind | Files |', '|---|---|');
|
|
126
|
+
for (const k of s.surface.surfaces) p(`| ${k.kind} | ${k.count} |`);
|
|
127
|
+
p('');
|
|
128
|
+
|
|
129
|
+
if (s.codemap) p(...renderEntryPoints(s.codemap));
|
|
130
|
+
p(...reportSection(s.stack));
|
|
131
|
+
|
|
132
|
+
p('## Unverified controls (NOT ASSESSED)', '');
|
|
133
|
+
p('Nothing in the files settled these. They are open, not passed. Each group says what settles it.', '');
|
|
134
|
+
for (const st of STATE_ORDER) {
|
|
135
|
+
const rows = s.results.filter((r) => r.result === `NOT ASSESSED: ${st}`);
|
|
136
|
+
if (!rows.length) continue;
|
|
137
|
+
p(`### NOT ASSESSED: ${st} (${rows.length})`, '', `${STATE_MEANS[st]} ${OWNER_ACTION[st]}`, '');
|
|
138
|
+
p('| ID | Severity | Requirement |', '|---|---|---|');
|
|
139
|
+
for (const r of rows.slice().sort((a, b) => SEV_RANK[kit.byId.get(b.id)!.severity] - SEV_RANK[kit.byId.get(a.id)!.severity] || cmp(a.id, b.id))) {
|
|
140
|
+
const c = kit.byId.get(r.id)!;
|
|
141
|
+
p(`| ${r.id} | ${c.severity} | ${cell(c.title)} |`);
|
|
142
|
+
}
|
|
143
|
+
p('');
|
|
144
|
+
}
|
|
145
|
+
const passes = s.results.filter((r) => r.result === 'PASS (static)');
|
|
146
|
+
if (passes.length) {
|
|
147
|
+
p('## PASS (static)', '', 'Settled by reading the repository. Only requirements whose authority is the repository can pass this way.', '');
|
|
148
|
+
p('| ID | Requirement | How |', '|---|---|---|');
|
|
149
|
+
for (const r of passes) p(`| ${r.id} | ${cell(kit.byId.get(r.id)?.title ?? '')} | ${cell(r.notes)} |`);
|
|
150
|
+
p('');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
p('## Reproducibility', '');
|
|
154
|
+
p('The same commit, engine version, kit files and tool versions give the same bytes in every file of the scan folder except run.json and scan/run.json.', '');
|
|
155
|
+
p('| Item | Value |', '|---|---|');
|
|
156
|
+
p(`| Engine | ${ENGINE_VERSION} |`, `| Controls file sha256 | ${s.info.kit.controls_sha256} |`, `| Checks | ${s.info.checks.total} |`);
|
|
157
|
+
for (const m of s.metas) p(`| ${m.tool} | ${m.version ?? 'not run'}, config sha256 ${m.config_sha256 ?? 'none'}, files scanned ${m.files_scanned ?? 'unknown'} of ${m.files_tracked ?? 'unknown'} tracked${m.db_date ? `, advisory data ${m.db_date}` : ''} |`);
|
|
158
|
+
p('');
|
|
159
|
+
return out.join('\n');
|
|
160
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// surface.json: what the scan looked at, by kind of entry point or configuration, so the report can say what was read.
|
|
2
|
+
// A file level summary found with the same selectors the older checks use. The entry point inventory is in codemap.json.
|
|
3
|
+
import type { CheckContext } from '../checks/context.ts';
|
|
4
|
+
import { handlerFiles } from '../checks/code/shared.ts';
|
|
5
|
+
|
|
6
|
+
export interface SurfaceFile {
|
|
7
|
+
schema_version: '1.0.0';
|
|
8
|
+
files_listed: number;
|
|
9
|
+
surfaces: { kind: string; count: number; files: string[] }[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const MAX_LISTED = 50;
|
|
13
|
+
|
|
14
|
+
export function surface(ctx: CheckContext): SurfaceFile {
|
|
15
|
+
const handlers = handlerFiles(ctx);
|
|
16
|
+
const kinds: [string, string[]][] = [
|
|
17
|
+
['server request handlers', handlers],
|
|
18
|
+
['webhook handlers', handlers.filter((f) => /webhook/i.test(f))],
|
|
19
|
+
['admin handlers', handlers.filter((f) => /(^|\/)(admin|backoffice|staff)(\/|\.|$)/i.test(f))],
|
|
20
|
+
['sign in and account routes', handlers.filter((f) => /login|signin|sign-in|signup|sign-up|register|password|otp|auth/i.test(f))],
|
|
21
|
+
['AI tool and model code', ctx.select(['**/{ai,llm,agent,agents,mcp}/**/*', '**/*tool*.{ts,js,py}'])],
|
|
22
|
+
['database migrations and rules', ctx.select(['**/migrations/**/*.sql', '**/schema.sql', '**/*.rules', '**/database.rules.json'])],
|
|
23
|
+
['CI workflows', ctx.select(['.github/workflows/*.{yml,yaml}', '.gitlab-ci.yml', 'bitbucket-pipelines.yml', '.circleci/config.yml', 'azure-pipelines.yml'])],
|
|
24
|
+
['infrastructure code', ctx.select(['**/*.tf', '**/*.tf.json', '**/*.bicep', '**/template.{yml,yaml}', '**/Dockerfile*', '**/*compose*.{yml,yaml}', '**/k8s/**/*.{yml,yaml}'])],
|
|
25
|
+
['framework and platform configuration', ctx.select(['next.config.*', 'nuxt.config.*', 'vite.config.*', 'vercel.json', 'netlify.toml', 'firebase.json', 'supabase/config.toml', '**/settings.py', 'app.json', 'app.config.*'])],
|
|
26
|
+
];
|
|
27
|
+
return {
|
|
28
|
+
schema_version: '1.0.0',
|
|
29
|
+
files_listed: ctx.files.length,
|
|
30
|
+
surfaces: kinds.map(([kind, files]) => ({ kind, count: files.length, files: files.slice(0, MAX_LISTED) })),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Test code: findings in test, fixture, spec, mock and example files are kept, marked as test code and reported apart.
|
|
2
|
+
// They never fail a requirement or reach the gate, except for requirements whose subject is test material that ships
|
|
3
|
+
// (TEST_ARTIFACT_IDS), where a test file is exactly the thing the requirement is about.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
// A path is test code when a folder in it, or its file name, follows a convention the language's own tools use:
|
|
9
|
+
//
|
|
10
|
+
// | Kind | Folder names (any folder in the path, any case) | File names |
|
|
11
|
+
// |---|---|---|
|
|
12
|
+
// | any language | test, tests, __tests__, __test__, spec, specs, e2e, cypress, __mocks__, mocks, mock, fixtures, fixture, __fixtures__, testdata, test-data, test_data, testfixtures, androidtest, example, examples, sample, samples | |
|
|
13
|
+
// | JavaScript, TypeScript | | *.test.*, *.spec.*, *.e2e.*, *.cy.*, *.mock.*, *.fixture.*, *.fixtures.* (js, jsx, ts, tsx, mjs, cjs, mts, cts) |
|
|
14
|
+
// | Python | | test_*.py, *_test.py, conftest.py, tests.py |
|
|
15
|
+
// | Go | | *_test.go (testdata/ is Go's fixture folder, above) |
|
|
16
|
+
// | Java, Kotlin, Groovy, Scala | | *Test, *Tests, *IT, *Spec (.java, .kt, .groovy, .scala); src/test/ through the folder rule |
|
|
17
|
+
// | PHP | | *Test.php |
|
|
18
|
+
// | Ruby | | *_spec.rb, *_test.rb |
|
|
19
|
+
// | C# | | *Test.cs, *Tests.cs |
|
|
20
|
+
// | Dart | | *_test.dart |
|
|
21
|
+
//
|
|
22
|
+
// "testing" is not on the list: Go and Python use it for shipped helper packages. A production file that happens to sit
|
|
23
|
+
// in a folder named like this (an `examples/` folder that is deployed, for instance) is still marked test code; the
|
|
24
|
+
// report lists every such record so a person can see it.
|
|
25
|
+
|
|
26
|
+
const TEST_FOLDERS = new Set([
|
|
27
|
+
'test', 'tests', '__tests__', '__test__', 'spec', 'specs', 'e2e', 'cypress', '__mocks__', 'mocks', 'mock', 'fixtures',
|
|
28
|
+
'fixture', '__fixtures__', 'testdata', 'test-data', 'test_data', 'testfixtures', 'androidtest', 'example', 'examples',
|
|
29
|
+
'sample', 'samples',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const TEST_FILES: { re: RegExp; kind: string }[] = [
|
|
33
|
+
{ re: /\.(test|spec|e2e|cy|mock|fixtures?)\.[cm]?[jt]sx?$/i, kind: 'JavaScript or TypeScript test file name' },
|
|
34
|
+
{ re: /^(test_.*|.*_test|conftest|tests)\.py$/, kind: 'Python test file name' },
|
|
35
|
+
{ re: /_test\.go$/, kind: 'Go test file name' },
|
|
36
|
+
{ re: /(Test|Tests|IT|Spec)\.(java|kt|groovy|scala)$/, kind: 'JVM test class name' },
|
|
37
|
+
{ re: /Test\.php$/, kind: 'PHP test class name' },
|
|
38
|
+
{ re: /_(spec|test)\.rb$/, kind: 'Ruby test file name' },
|
|
39
|
+
{ re: /Tests?\.cs$/, kind: 'C# test class name' },
|
|
40
|
+
{ re: /_test\.dart$/, kind: 'Dart test file name' },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// Requirements about test material in the shipped product (test routes, seed scripts and sample code kept out of
|
|
44
|
+
// production, seed and fixture data kept out of the migration chain): a hit in a test path still fails these. The IDs
|
|
45
|
+
// are data in rules/test-artifact-ids.json, so each edition of the rules lists the ones it has.
|
|
46
|
+
const ARTIFACT_FILE = fileURLToPath(new URL('../../rules/test-artifact-ids.json', import.meta.url));
|
|
47
|
+
export const TEST_ARTIFACT_IDS = new Set<string>(existsSync(ARTIFACT_FILE) ? (JSON.parse(readFileSync(ARTIFACT_FILE, 'utf8')) as { sec_ids: string[] }).sec_ids : []);
|
|
48
|
+
|
|
49
|
+
// Why a repository relative POSIX path counts as test code, or null when it does not.
|
|
50
|
+
export function testCodeReason(file: string): string | null {
|
|
51
|
+
const parts = file.split('/');
|
|
52
|
+
const name = parts.pop() ?? '';
|
|
53
|
+
const folder = parts.find((p) => TEST_FOLDERS.has(p.toLowerCase()));
|
|
54
|
+
if (folder) return `folder ${folder}/`;
|
|
55
|
+
return TEST_FILES.find((t) => t.re.test(name))?.kind ?? null;
|
|
56
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Rule confidence tiers (rules/tiers.json). A Semgrep rule is "gate" or "lead". A gate rule's hit fails its requirements
|
|
2
|
+
// as before. A lead rule's hit is listed in the report as a lead and never fails a requirement or holds the gate.
|
|
3
|
+
// The tier comes from measurement: a rule whose measured precision is below the threshold in the file is a lead until a
|
|
4
|
+
// later measurement raises it. A rule not in the file keeps a default: engine taint rules (taint-*.yaml, rule ids
|
|
5
|
+
// hullproof-taint-*) are leads, every other rule is gate, which is how the kit rules with fixtures behaved before tiers.
|
|
6
|
+
// Gitleaks hits, Semgrep rules mapped to a SEC-SECRETS ID, engine checks, Trivy and OSV are always gate.
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import type { Hit } from '../types/index.ts';
|
|
10
|
+
|
|
11
|
+
// needs_judged: a minimum judged count for this entry alone, above the file's min_judged (a decision to wait for more evidence).
|
|
12
|
+
export interface TierEntry { tier: 'gate' | 'lead'; precision: number | null; judged: number; evidence: string; date: string | null; needs_judged?: number }
|
|
13
|
+
export interface TierFile { threshold: number; min_judged: number; rules: Record<string, TierEntry> }
|
|
14
|
+
|
|
15
|
+
// The tier file sits next to each Semgrep rule folder (rules/tiers.json, and a tier file next to a rule pack). Later folders (the rule
|
|
16
|
+
// pack) win for the same rule id; the threshold and minimum come from the first file found.
|
|
17
|
+
const DEFAULT: TierFile = { threshold: 0.8, min_judged: 3, rules: {} };
|
|
18
|
+
export function loadTiers(semgrepDirs: string[]): TierFile {
|
|
19
|
+
const files = semgrepDirs.map((d) => join(dirname(d), 'tiers.json')).filter((p) => existsSync(p)).map((p) => JSON.parse(readFileSync(p, 'utf8')) as TierFile);
|
|
20
|
+
if (!files.length) return DEFAULT;
|
|
21
|
+
return { threshold: files[0].threshold, min_judged: files[0].min_judged, rules: Object.assign({}, ...files.map((f) => f.rules)) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A hit that is about a secret: a live key is a leak wherever it is committed, so it always holds the gate.
|
|
25
|
+
export const isSecretHit = (h: Hit): boolean => h.tool === 'gitleaks' || h.sec_ids.some((id) => id.startsWith('SEC-SECRETS-'));
|
|
26
|
+
|
|
27
|
+
// The tier entry for a hit, or null when the hit is gate.
|
|
28
|
+
export function leadTier(h: Hit, t: TierFile): TierEntry | null {
|
|
29
|
+
if (h.tool !== 'semgrep' || isSecretHit(h)) return null;
|
|
30
|
+
const e = t.rules[h.rule_id] ?? (h.rule_id.startsWith('hullproof-taint-')
|
|
31
|
+
? { tier: 'lead' as const, precision: null, judged: 0, evidence: 'not measured yet', date: null } : null);
|
|
32
|
+
return e && e.tier === 'lead' ? e : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// An engine check that reads the code map (check.map.*) is a lead until a measurement puts it in tiers.json with a precision at
|
|
36
|
+
// or above the threshold. Every other engine check is gate, as before.
|
|
37
|
+
export const isMapCheck = (id: string): boolean => id.startsWith('check.map.');
|
|
38
|
+
export function leadTierForCheck(id: string, t: TierFile): TierEntry | null {
|
|
39
|
+
if (!isMapCheck(id)) return null;
|
|
40
|
+
const e = t.rules[id] ?? { tier: 'lead' as const, precision: null, judged: 0, evidence: 'not measured yet', date: null };
|
|
41
|
+
return e.tier === 'lead' ? e : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Plain English reason for a lead record.
|
|
45
|
+
export function leadReason(e: TierEntry, t: TierFile): string {
|
|
46
|
+
if (e.precision === null || e.judged === 0) return 'this rule has not been measured yet';
|
|
47
|
+
const m = `precision ${e.precision.toFixed(2)} on ${e.judged} judged hits (${e.evidence}, ${e.date})`;
|
|
48
|
+
const need = Math.max(t.min_judged, e.needs_judged ?? 0);
|
|
49
|
+
return e.judged < need ? `${m}, fewer than the ${need} needed to change its default` : `${m}, below ${t.threshold.toFixed(2)}`;
|
|
50
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Helpers shared by the adapters: running a tool, reading the git index without git, hashing, masking, path rules.
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
5
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { cmp } from '../util.ts';
|
|
8
|
+
import type { Exec } from './types.ts';
|
|
9
|
+
|
|
10
|
+
export const ENGINE_ROOT = fileURLToPath(new URL('../../', import.meta.url));
|
|
11
|
+
export const RULES_DIR = join(ENGINE_ROOT, 'rules');
|
|
12
|
+
const MAX_BUFFER = 512 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
// An address nothing listens on. Go, Python and git clients send all HTTP and HTTPS traffic to it and fail,
|
|
15
|
+
// so a tool that tries the network while network is not allowed gets an error instead of a connection.
|
|
16
|
+
const BLACKHOLE = 'http://127.0.0.1:9';
|
|
17
|
+
|
|
18
|
+
export function toolEnv(base: NodeJS.ProcessEnv, allowNetwork: boolean): NodeJS.ProcessEnv {
|
|
19
|
+
const env: NodeJS.ProcessEnv = {
|
|
20
|
+
...base,
|
|
21
|
+
GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null', GIT_CONFIG_NOSYSTEM: '1',
|
|
22
|
+
GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0', NO_COLOR: '1',
|
|
23
|
+
SEMGREP_SEND_METRICS: 'off', SEMGREP_ENABLE_VERSION_CHECK: '0',
|
|
24
|
+
// Set by the container entry point of the GitHub Action only (see src/commands/git.ts).
|
|
25
|
+
...(process.env.HULLPROOF_TRUST_REPO_OWNER === '1' ? { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'safe.directory', GIT_CONFIG_VALUE_0: '*' } : {}),
|
|
26
|
+
};
|
|
27
|
+
if (!allowNetwork) {
|
|
28
|
+
for (const k of ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'http_proxy', 'https_proxy', 'all_proxy']) env[k] = BLACKHOLE;
|
|
29
|
+
env.NO_PROXY = ''; env.no_proxy = '';
|
|
30
|
+
}
|
|
31
|
+
return env;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function exec(argv: string[], cwd: string, env: NodeJS.ProcessEnv): Exec {
|
|
35
|
+
const r = spawnSync(argv[0], argv.slice(1), { cwd, env, encoding: 'utf8', maxBuffer: MAX_BUFFER, shell: false });
|
|
36
|
+
const error = r.error ? ((r.error as NodeJS.ErrnoException).code ?? r.error.message) : undefined;
|
|
37
|
+
return { argv, stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status, error };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// First x.y.z in the version command's output. null when the tool is missing.
|
|
41
|
+
export function detectVersion(argv: string[], env: NodeJS.ProcessEnv): string | null {
|
|
42
|
+
const r = exec(argv, process.cwd(), env);
|
|
43
|
+
if (r.error) return null;
|
|
44
|
+
return /(\d+\.\d+\.\d+)/.exec(r.stdout + '\n' + r.stderr)?.[1] ?? null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// "1.176.x" style ranges: the installed version must have the same major and minor.
|
|
48
|
+
export function versionTested(version: string | null, range: string): boolean {
|
|
49
|
+
if (!version) return false;
|
|
50
|
+
const [maj, min] = range.split('.');
|
|
51
|
+
const [vmaj, vmin] = version.split('.');
|
|
52
|
+
return maj === vmaj && min === vmin;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const sha256 = (data: string | Buffer): string => createHash('sha256').update(data).digest('hex');
|
|
56
|
+
export const fileSha256 = (p: string): string => sha256(readFileSync(p));
|
|
57
|
+
|
|
58
|
+
// The scan.sh method for a folder: sha256 over the per file sha256 lines, files in sorted path order.
|
|
59
|
+
export function dirSha256(dir: string): string {
|
|
60
|
+
const files: string[] = [];
|
|
61
|
+
const walk = (d: string): void => {
|
|
62
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
63
|
+
const p = join(d, e.name);
|
|
64
|
+
if (e.isDirectory()) walk(p); else if (e.isFile()) files.push(relative(dir, p).split(sep).join('/'));
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
walk(dir);
|
|
68
|
+
return sha256(files.sort(cmp).map((f) => fileSha256(join(dir, f)) + '\n').join(''));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Repository relative POSIX path. Absolute paths inside the repository lose the prefix; "./" is dropped.
|
|
72
|
+
export function relPath(p: string, repo: string): string {
|
|
73
|
+
let out = p.replace(/\\/g, '/');
|
|
74
|
+
if (isAbsolute(p)) {
|
|
75
|
+
const r = relative(resolve(repo), p);
|
|
76
|
+
out = r.startsWith('..') ? out : r.split(sep).join('/');
|
|
77
|
+
}
|
|
78
|
+
return out.replace(/^(\.\/)+/, '');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Replaces long token shaped runs (keys, hashes, encoded blobs: 20 or more key characters with both a letter and a
|
|
82
|
+
// digit) so evidence text does not carry a secret value. Plain identifiers such as AWS_SECRET_ACCESS_KEY stay readable.
|
|
83
|
+
// ponytail: a secret with no digit passes; evidence for secret rules is the Gitleaks redacted form or null anyway.
|
|
84
|
+
const TOKEN = /[A-Za-z0-9+/_-]{20,}={0,2}/g;
|
|
85
|
+
export function mask(text: string, max = 160): string {
|
|
86
|
+
const one = text.replace(/\s+/g, ' ').trim().replace(TOKEN, (m) => (/\d/.test(m) && /[A-Za-z]/.test(m) ? '***' : m));
|
|
87
|
+
return one.length > max ? one.slice(0, max - 3) + '...' : one;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// One masked source line read from the repository, or null when the file or line is not there.
|
|
91
|
+
export function sourceLine(repo: string, file: string, line: number): string | null {
|
|
92
|
+
if (line < 1) return null;
|
|
93
|
+
try {
|
|
94
|
+
const p = join(repo, file);
|
|
95
|
+
if (!lstatSync(p).isFile() || statSync(p).size > 4 * 1024 * 1024) return null;
|
|
96
|
+
const text = readFileSync(p, 'utf8').split('\n')[line - 1];
|
|
97
|
+
return text === undefined ? null : mask(text);
|
|
98
|
+
} catch { return null; }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Paths tracked by git, read from .git/index (versions 2, 3 and 4) without running git. Stage 0 to 3 entries are one
|
|
102
|
+
// path; submodule entries (mode 160000) are left out. null when there is no index.
|
|
103
|
+
export function readTracked(repo: string): Set<string> | null {
|
|
104
|
+
let gitDir = join(repo, '.git');
|
|
105
|
+
if (!existsSync(gitDir)) return null;
|
|
106
|
+
if (statSync(gitDir).isFile()) {
|
|
107
|
+
const m = /^gitdir:\s*(.+)$/m.exec(readFileSync(gitDir, 'utf8'));
|
|
108
|
+
if (!m) return null;
|
|
109
|
+
gitDir = resolve(repo, m[1].trim());
|
|
110
|
+
}
|
|
111
|
+
const idx = join(gitDir, 'index');
|
|
112
|
+
if (!existsSync(idx)) return null;
|
|
113
|
+
return parseIndex(readFileSync(idx));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function parseIndex(buf: Buffer): Set<string> {
|
|
117
|
+
if (buf.toString('latin1', 0, 4) !== 'DIRC') throw new Error('not a git index');
|
|
118
|
+
const version = buf.readUInt32BE(4);
|
|
119
|
+
if (version < 2 || version > 4) throw new Error(`git index version ${version} is not supported`);
|
|
120
|
+
const count = buf.readUInt32BE(8);
|
|
121
|
+
const out = new Set<string>();
|
|
122
|
+
let off = 12;
|
|
123
|
+
let prev: Buffer = Buffer.alloc(0);
|
|
124
|
+
for (let i = 0; i < count; i++) {
|
|
125
|
+
const start = off;
|
|
126
|
+
const mode = buf.readUInt32BE(off + 24);
|
|
127
|
+
const flags = buf.readUInt16BE(off + 60);
|
|
128
|
+
off += 62;
|
|
129
|
+
if (version >= 3 && flags & 0x4000) off += 2;
|
|
130
|
+
let name: Buffer;
|
|
131
|
+
if (version === 4) {
|
|
132
|
+
let c = buf[off++];
|
|
133
|
+
let strip = c & 127;
|
|
134
|
+
while (c & 128) { c = buf[off++]; strip = ((strip + 1) << 7) | (c & 127); }
|
|
135
|
+
const end = buf.indexOf(0, off);
|
|
136
|
+
name = Buffer.concat([prev.subarray(0, prev.length - strip), buf.subarray(off, end)]);
|
|
137
|
+
off = end + 1;
|
|
138
|
+
} else {
|
|
139
|
+
const end = buf.indexOf(0, off);
|
|
140
|
+
name = buf.subarray(off, end);
|
|
141
|
+
off = start + Math.floor((end - start + 8) / 8) * 8; // NUL padding to a multiple of 8
|
|
142
|
+
}
|
|
143
|
+
prev = name;
|
|
144
|
+
if ((mode >>> 12) !== 0o16) out.add(name.toString('utf8')); // 0o160000 is a submodule
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export const isRegularFile = (p: string): boolean => { try { return lstatSync(p).isFile(); } catch { return false; } };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Gitleaks adapter. `gitleaks dir` over the working tree always; `gitleaks git` over history only when the caller says
|
|
2
|
+
// the git configuration was checked (opts.history), because it runs git. Both use the kit config in rules/gitleaks.toml,
|
|
3
|
+
// --redact, --ignore-gitleaks-allow and an empty ignore folder, so the scanned repository cannot hide its own findings.
|
|
4
|
+
// Kept per hit: rule id, file, line, the redacted match (masked again) and the Gitleaks fingerprint. Never the secret,
|
|
5
|
+
// the entropy, the author or the date.
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { secretNoise } from './secret-noise.ts';
|
|
9
|
+
import { sortUnique } from '../util.ts';
|
|
10
|
+
import { fileSha256, mask, relPath } from './common.ts';
|
|
11
|
+
import { inEdition, loadControls } from '../mapping/controls.ts';
|
|
12
|
+
import { mapHit, tableSecIds } from '../mapping/map.ts';
|
|
13
|
+
import type { Adapter, DetailHit, Env, Exec, ScanOptions } from './types.ts';
|
|
14
|
+
|
|
15
|
+
interface Finding { RuleID: string; StartLine: number; EndLine: number; Match: string; File: string; Commit: string; Fingerprint: string }
|
|
16
|
+
|
|
17
|
+
const flags = (config: string, ignoreDir: string): string[] => ['--redact', '--no-banner', '--no-color', '--config', config,
|
|
18
|
+
'--ignore-gitleaks-allow', '--gitleaks-ignore-path', ignoreDir, '--report-format', 'json', '--report-path', '-', '--exit-code', '0'];
|
|
19
|
+
|
|
20
|
+
export const adapter: Adapter = {
|
|
21
|
+
id: 'gitleaks',
|
|
22
|
+
tool: 'gitleaks',
|
|
23
|
+
versionCommand: ['gitleaks', 'version'],
|
|
24
|
+
testedVersions: '8.30.x',
|
|
25
|
+
needsNetwork: false,
|
|
26
|
+
applies: () => true,
|
|
27
|
+
notApplicableReason: () => null,
|
|
28
|
+
configFiles: (ctx) => [join((ctx as ScanOptions).rulesDir ?? '', 'gitleaks.toml')],
|
|
29
|
+
command: (ctx) => ['gitleaks', 'dir', '.', ...flags(join((ctx as ScanOptions).rulesDir ?? '', 'gitleaks.toml'), '<empty-dir>')],
|
|
30
|
+
parse: (stdout, ctx) => parseGitleaks(stdout, ctx as ScanOptions, 'dir', null, '').hits,
|
|
31
|
+
possibleSecIds: (o) => sortUnique(inEdition(tableSecIds('gitleaks'), o.edition, loadControls(o.controlsPath))),
|
|
32
|
+
|
|
33
|
+
execute(o, env) {
|
|
34
|
+
const config = join(env.rulesDir, 'gitleaks.toml');
|
|
35
|
+
const configSha256 = fileSha256(config);
|
|
36
|
+
const ruleVersion = `gitleaks ${env.version}, config sha256:${configSha256.slice(0, 16)}`;
|
|
37
|
+
const show = (argv: string[]): string[] => argv.map((a) => (a === config ? '<engine>/rules/gitleaks.toml' : a === env.scratch ? '<empty-dir>' : a));
|
|
38
|
+
const runOne = (mode: 'dir' | 'git'): Exec => {
|
|
39
|
+
// Trace logging is the only place Gitleaks names each file it read. The log also prints skipped candidate values,
|
|
40
|
+
// so stderr is read here for the file names only, never stored and never printed.
|
|
41
|
+
const argv = ['gitleaks', mode, '.', ...flags(config, env.scratch), ...(mode === 'dir' ? ['--log-level', 'trace'] : [])];
|
|
42
|
+
const r = env.run(argv);
|
|
43
|
+
if (r.status !== 0) throw new Error(`gitleaks ${mode} exited with ${r.status ?? r.error}`);
|
|
44
|
+
return r;
|
|
45
|
+
};
|
|
46
|
+
const dir = runOne('dir');
|
|
47
|
+
// The engine's own output folder is left out: it is written by this tool, so reading it would change the next run.
|
|
48
|
+
const own = (f: string): boolean => f === '.hullproof' || f.startsWith('.hullproof/');
|
|
49
|
+
const scanned = sortUnique([...dir.stderr.matchAll(/scanning path path=(.+)$/gm)].map((m) => relPath(m[1].trim(), o.repo))).filter((f) => !own(f));
|
|
50
|
+
const hits = parseGitleaks(dir.stdout, o, 'dir', env.tracked, ruleVersion).hits.filter((h) => !own(h.file));
|
|
51
|
+
const commands = [show(dir.argv)];
|
|
52
|
+
if (o.history) {
|
|
53
|
+
const git = runOne('git');
|
|
54
|
+
hits.push(...parseGitleaks(git.stdout, o, 'git', env.tracked, ruleVersion).hits);
|
|
55
|
+
commands.push(show(git.argv));
|
|
56
|
+
}
|
|
57
|
+
return { hits, commands, configSha256, scanned, exitCode: dir.status, toolErrors: [] };
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export function parseGitleaks(stdout: string, o: ScanOptions, source: 'dir' | 'git', tracked: Set<string> | null, ruleVersion: string): { hits: DetailHit[] } {
|
|
62
|
+
const controls = loadControls(o.controlsPath);
|
|
63
|
+
const list = (stdout.trim() ? JSON.parse(stdout) : []) as Finding[];
|
|
64
|
+
// The source line is the only place a placeholder can be told from a key, because the scanner redacts the value. History hits
|
|
65
|
+
// cannot be matched to the checked out files, so they get the path rules only.
|
|
66
|
+
const lines = new Map<string, string[] | null>();
|
|
67
|
+
const lineOf = (file: string, n: number): string | null => {
|
|
68
|
+
if (source === 'git') return null;
|
|
69
|
+
if (!lines.has(file)) { try { lines.set(file, readFileSync(join(o.repo, file), 'utf8').split('\n')); } catch { lines.set(file, null); } }
|
|
70
|
+
return lines.get(file)?.[n - 1] ?? null;
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
hits: list.map((f): DetailHit => {
|
|
74
|
+
const file = relPath(f.File, o.repo);
|
|
75
|
+
const isTracked = source === 'git' || !tracked || tracked.has(file);
|
|
76
|
+
const m = mapHit({ tool: 'gitleaks', rule_id: f.RuleID, file, tracked: isTracked }, o.edition, controls);
|
|
77
|
+
const redacted = mask(f.Match);
|
|
78
|
+
const noise = secretNoise(f.RuleID, file, lineOf(file, f.StartLine));
|
|
79
|
+
return {
|
|
80
|
+
tool: 'gitleaks', rule_id: f.RuleID, file, line: f.StartLine, end_line: f.EndLine, sec_ids: m.sec_ids, severity_hint: null,
|
|
81
|
+
...(noise ? { noise } : {}), message_key: `gitleaks:${f.RuleID}`, rule_version: ruleVersion, evidence: redacted, mapped_by: m.mapped_by, unmapped: !m.sec_ids.length,
|
|
82
|
+
secret: { redacted, fingerprint: relFingerprint(f.Fingerprint, o.repo), source, commit: f.Commit || null, tracked: isTracked },
|
|
83
|
+
};
|
|
84
|
+
}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The fingerprint is [commit:]file:rule:line; make its file part repository relative too.
|
|
89
|
+
const relFingerprint = (fp: string, repo: string): string => fp.includes(repo) ? fp.split(repo.replace(/\/$/, '') + '/').join('') : fp;
|