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,162 @@
|
|
|
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.js';
|
|
8
|
+
export const ENGINE_ROOT = fileURLToPath(new URL('../../', import.meta.url));
|
|
9
|
+
export const RULES_DIR = join(ENGINE_ROOT, 'rules');
|
|
10
|
+
const MAX_BUFFER = 512 * 1024 * 1024;
|
|
11
|
+
// An address nothing listens on. Go, Python and git clients send all HTTP and HTTPS traffic to it and fail,
|
|
12
|
+
// so a tool that tries the network while network is not allowed gets an error instead of a connection.
|
|
13
|
+
const BLACKHOLE = 'http://127.0.0.1:9';
|
|
14
|
+
export function toolEnv(base, allowNetwork) {
|
|
15
|
+
const env = {
|
|
16
|
+
...base,
|
|
17
|
+
GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null', GIT_CONFIG_NOSYSTEM: '1',
|
|
18
|
+
GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0', NO_COLOR: '1',
|
|
19
|
+
SEMGREP_SEND_METRICS: 'off', SEMGREP_ENABLE_VERSION_CHECK: '0',
|
|
20
|
+
// Set by the container entry point of the GitHub Action only (see src/commands/git.ts).
|
|
21
|
+
...(process.env.HULLPROOF_TRUST_REPO_OWNER === '1' ? { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'safe.directory', GIT_CONFIG_VALUE_0: '*' } : {}),
|
|
22
|
+
};
|
|
23
|
+
if (!allowNetwork) {
|
|
24
|
+
for (const k of ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'http_proxy', 'https_proxy', 'all_proxy'])
|
|
25
|
+
env[k] = BLACKHOLE;
|
|
26
|
+
env.NO_PROXY = '';
|
|
27
|
+
env.no_proxy = '';
|
|
28
|
+
}
|
|
29
|
+
return env;
|
|
30
|
+
}
|
|
31
|
+
export function exec(argv, cwd, env) {
|
|
32
|
+
const r = spawnSync(argv[0], argv.slice(1), { cwd, env, encoding: 'utf8', maxBuffer: MAX_BUFFER, shell: false });
|
|
33
|
+
const error = r.error ? (r.error.code ?? r.error.message) : undefined;
|
|
34
|
+
return { argv, stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status, error };
|
|
35
|
+
}
|
|
36
|
+
// First x.y.z in the version command's output. null when the tool is missing.
|
|
37
|
+
export function detectVersion(argv, env) {
|
|
38
|
+
const r = exec(argv, process.cwd(), env);
|
|
39
|
+
if (r.error)
|
|
40
|
+
return null;
|
|
41
|
+
return /(\d+\.\d+\.\d+)/.exec(r.stdout + '\n' + r.stderr)?.[1] ?? null;
|
|
42
|
+
}
|
|
43
|
+
// "1.176.x" style ranges: the installed version must have the same major and minor.
|
|
44
|
+
export function versionTested(version, range) {
|
|
45
|
+
if (!version)
|
|
46
|
+
return false;
|
|
47
|
+
const [maj, min] = range.split('.');
|
|
48
|
+
const [vmaj, vmin] = version.split('.');
|
|
49
|
+
return maj === vmaj && min === vmin;
|
|
50
|
+
}
|
|
51
|
+
export const sha256 = (data) => createHash('sha256').update(data).digest('hex');
|
|
52
|
+
export const fileSha256 = (p) => sha256(readFileSync(p));
|
|
53
|
+
// The scan.sh method for a folder: sha256 over the per file sha256 lines, files in sorted path order.
|
|
54
|
+
export function dirSha256(dir) {
|
|
55
|
+
const files = [];
|
|
56
|
+
const walk = (d) => {
|
|
57
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
58
|
+
const p = join(d, e.name);
|
|
59
|
+
if (e.isDirectory())
|
|
60
|
+
walk(p);
|
|
61
|
+
else if (e.isFile())
|
|
62
|
+
files.push(relative(dir, p).split(sep).join('/'));
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
walk(dir);
|
|
66
|
+
return sha256(files.sort(cmp).map((f) => fileSha256(join(dir, f)) + '\n').join(''));
|
|
67
|
+
}
|
|
68
|
+
// Repository relative POSIX path. Absolute paths inside the repository lose the prefix; "./" is dropped.
|
|
69
|
+
export function relPath(p, repo) {
|
|
70
|
+
let out = p.replace(/\\/g, '/');
|
|
71
|
+
if (isAbsolute(p)) {
|
|
72
|
+
const r = relative(resolve(repo), p);
|
|
73
|
+
out = r.startsWith('..') ? out : r.split(sep).join('/');
|
|
74
|
+
}
|
|
75
|
+
return out.replace(/^(\.\/)+/, '');
|
|
76
|
+
}
|
|
77
|
+
// Replaces long token shaped runs (keys, hashes, encoded blobs: 20 or more key characters with both a letter and a
|
|
78
|
+
// digit) so evidence text does not carry a secret value. Plain identifiers such as AWS_SECRET_ACCESS_KEY stay readable.
|
|
79
|
+
// ponytail: a secret with no digit passes; evidence for secret rules is the Gitleaks redacted form or null anyway.
|
|
80
|
+
const TOKEN = /[A-Za-z0-9+/_-]{20,}={0,2}/g;
|
|
81
|
+
export function mask(text, max = 160) {
|
|
82
|
+
const one = text.replace(/\s+/g, ' ').trim().replace(TOKEN, (m) => (/\d/.test(m) && /[A-Za-z]/.test(m) ? '***' : m));
|
|
83
|
+
return one.length > max ? one.slice(0, max - 3) + '...' : one;
|
|
84
|
+
}
|
|
85
|
+
// One masked source line read from the repository, or null when the file or line is not there.
|
|
86
|
+
export function sourceLine(repo, file, line) {
|
|
87
|
+
if (line < 1)
|
|
88
|
+
return null;
|
|
89
|
+
try {
|
|
90
|
+
const p = join(repo, file);
|
|
91
|
+
if (!lstatSync(p).isFile() || statSync(p).size > 4 * 1024 * 1024)
|
|
92
|
+
return null;
|
|
93
|
+
const text = readFileSync(p, 'utf8').split('\n')[line - 1];
|
|
94
|
+
return text === undefined ? null : mask(text);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Paths tracked by git, read from .git/index (versions 2, 3 and 4) without running git. Stage 0 to 3 entries are one
|
|
101
|
+
// path; submodule entries (mode 160000) are left out. null when there is no index.
|
|
102
|
+
export function readTracked(repo) {
|
|
103
|
+
let gitDir = join(repo, '.git');
|
|
104
|
+
if (!existsSync(gitDir))
|
|
105
|
+
return null;
|
|
106
|
+
if (statSync(gitDir).isFile()) {
|
|
107
|
+
const m = /^gitdir:\s*(.+)$/m.exec(readFileSync(gitDir, 'utf8'));
|
|
108
|
+
if (!m)
|
|
109
|
+
return null;
|
|
110
|
+
gitDir = resolve(repo, m[1].trim());
|
|
111
|
+
}
|
|
112
|
+
const idx = join(gitDir, 'index');
|
|
113
|
+
if (!existsSync(idx))
|
|
114
|
+
return null;
|
|
115
|
+
return parseIndex(readFileSync(idx));
|
|
116
|
+
}
|
|
117
|
+
export function parseIndex(buf) {
|
|
118
|
+
if (buf.toString('latin1', 0, 4) !== 'DIRC')
|
|
119
|
+
throw new Error('not a git index');
|
|
120
|
+
const version = buf.readUInt32BE(4);
|
|
121
|
+
if (version < 2 || version > 4)
|
|
122
|
+
throw new Error(`git index version ${version} is not supported`);
|
|
123
|
+
const count = buf.readUInt32BE(8);
|
|
124
|
+
const out = new Set();
|
|
125
|
+
let off = 12;
|
|
126
|
+
let prev = Buffer.alloc(0);
|
|
127
|
+
for (let i = 0; i < count; i++) {
|
|
128
|
+
const start = off;
|
|
129
|
+
const mode = buf.readUInt32BE(off + 24);
|
|
130
|
+
const flags = buf.readUInt16BE(off + 60);
|
|
131
|
+
off += 62;
|
|
132
|
+
if (version >= 3 && flags & 0x4000)
|
|
133
|
+
off += 2;
|
|
134
|
+
let name;
|
|
135
|
+
if (version === 4) {
|
|
136
|
+
let c = buf[off++];
|
|
137
|
+
let strip = c & 127;
|
|
138
|
+
while (c & 128) {
|
|
139
|
+
c = buf[off++];
|
|
140
|
+
strip = ((strip + 1) << 7) | (c & 127);
|
|
141
|
+
}
|
|
142
|
+
const end = buf.indexOf(0, off);
|
|
143
|
+
name = Buffer.concat([prev.subarray(0, prev.length - strip), buf.subarray(off, end)]);
|
|
144
|
+
off = end + 1;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
const end = buf.indexOf(0, off);
|
|
148
|
+
name = buf.subarray(off, end);
|
|
149
|
+
off = start + Math.floor((end - start + 8) / 8) * 8; // NUL padding to a multiple of 8
|
|
150
|
+
}
|
|
151
|
+
prev = name;
|
|
152
|
+
if ((mode >>> 12) !== 0o16)
|
|
153
|
+
out.add(name.toString('utf8')); // 0o160000 is a submodule
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
export const isRegularFile = (p) => { try {
|
|
158
|
+
return lstatSync(p).isFile();
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return false;
|
|
162
|
+
} };
|
|
@@ -0,0 +1,90 @@
|
|
|
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.js';
|
|
9
|
+
import { sortUnique } from '../util.js';
|
|
10
|
+
import { fileSha256, mask, relPath } from './common.js';
|
|
11
|
+
import { inEdition, loadControls } from '../mapping/controls.js';
|
|
12
|
+
import { mapHit, tableSecIds } from '../mapping/map.js';
|
|
13
|
+
const flags = (config, ignoreDir) => ['--redact', '--no-banner', '--no-color', '--config', config,
|
|
14
|
+
'--ignore-gitleaks-allow', '--gitleaks-ignore-path', ignoreDir, '--report-format', 'json', '--report-path', '-', '--exit-code', '0'];
|
|
15
|
+
export const adapter = {
|
|
16
|
+
id: 'gitleaks',
|
|
17
|
+
tool: 'gitleaks',
|
|
18
|
+
versionCommand: ['gitleaks', 'version'],
|
|
19
|
+
testedVersions: '8.30.x',
|
|
20
|
+
needsNetwork: false,
|
|
21
|
+
applies: () => true,
|
|
22
|
+
notApplicableReason: () => null,
|
|
23
|
+
configFiles: (ctx) => [join(ctx.rulesDir ?? '', 'gitleaks.toml')],
|
|
24
|
+
command: (ctx) => ['gitleaks', 'dir', '.', ...flags(join(ctx.rulesDir ?? '', 'gitleaks.toml'), '<empty-dir>')],
|
|
25
|
+
parse: (stdout, ctx) => parseGitleaks(stdout, ctx, 'dir', null, '').hits,
|
|
26
|
+
possibleSecIds: (o) => sortUnique(inEdition(tableSecIds('gitleaks'), o.edition, loadControls(o.controlsPath))),
|
|
27
|
+
execute(o, env) {
|
|
28
|
+
const config = join(env.rulesDir, 'gitleaks.toml');
|
|
29
|
+
const configSha256 = fileSha256(config);
|
|
30
|
+
const ruleVersion = `gitleaks ${env.version}, config sha256:${configSha256.slice(0, 16)}`;
|
|
31
|
+
const show = (argv) => argv.map((a) => (a === config ? '<engine>/rules/gitleaks.toml' : a === env.scratch ? '<empty-dir>' : a));
|
|
32
|
+
const runOne = (mode) => {
|
|
33
|
+
// Trace logging is the only place Gitleaks names each file it read. The log also prints skipped candidate values,
|
|
34
|
+
// so stderr is read here for the file names only, never stored and never printed.
|
|
35
|
+
const argv = ['gitleaks', mode, '.', ...flags(config, env.scratch), ...(mode === 'dir' ? ['--log-level', 'trace'] : [])];
|
|
36
|
+
const r = env.run(argv);
|
|
37
|
+
if (r.status !== 0)
|
|
38
|
+
throw new Error(`gitleaks ${mode} exited with ${r.status ?? r.error}`);
|
|
39
|
+
return r;
|
|
40
|
+
};
|
|
41
|
+
const dir = runOne('dir');
|
|
42
|
+
// The engine's own output folder is left out: it is written by this tool, so reading it would change the next run.
|
|
43
|
+
const own = (f) => f === '.hullproof' || f.startsWith('.hullproof/');
|
|
44
|
+
const scanned = sortUnique([...dir.stderr.matchAll(/scanning path path=(.+)$/gm)].map((m) => relPath(m[1].trim(), o.repo))).filter((f) => !own(f));
|
|
45
|
+
const hits = parseGitleaks(dir.stdout, o, 'dir', env.tracked, ruleVersion).hits.filter((h) => !own(h.file));
|
|
46
|
+
const commands = [show(dir.argv)];
|
|
47
|
+
if (o.history) {
|
|
48
|
+
const git = runOne('git');
|
|
49
|
+
hits.push(...parseGitleaks(git.stdout, o, 'git', env.tracked, ruleVersion).hits);
|
|
50
|
+
commands.push(show(git.argv));
|
|
51
|
+
}
|
|
52
|
+
return { hits, commands, configSha256, scanned, exitCode: dir.status, toolErrors: [] };
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
export function parseGitleaks(stdout, o, source, tracked, ruleVersion) {
|
|
56
|
+
const controls = loadControls(o.controlsPath);
|
|
57
|
+
const list = (stdout.trim() ? JSON.parse(stdout) : []);
|
|
58
|
+
// The source line is the only place a placeholder can be told from a key, because the scanner redacts the value. History hits
|
|
59
|
+
// cannot be matched to the checked out files, so they get the path rules only.
|
|
60
|
+
const lines = new Map();
|
|
61
|
+
const lineOf = (file, n) => {
|
|
62
|
+
if (source === 'git')
|
|
63
|
+
return null;
|
|
64
|
+
if (!lines.has(file)) {
|
|
65
|
+
try {
|
|
66
|
+
lines.set(file, readFileSync(join(o.repo, file), 'utf8').split('\n'));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
lines.set(file, null);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return lines.get(file)?.[n - 1] ?? null;
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
hits: list.map((f) => {
|
|
76
|
+
const file = relPath(f.File, o.repo);
|
|
77
|
+
const isTracked = source === 'git' || !tracked || tracked.has(file);
|
|
78
|
+
const m = mapHit({ tool: 'gitleaks', rule_id: f.RuleID, file, tracked: isTracked }, o.edition, controls);
|
|
79
|
+
const redacted = mask(f.Match);
|
|
80
|
+
const noise = secretNoise(f.RuleID, file, lineOf(file, f.StartLine));
|
|
81
|
+
return {
|
|
82
|
+
tool: 'gitleaks', rule_id: f.RuleID, file, line: f.StartLine, end_line: f.EndLine, sec_ids: m.sec_ids, severity_hint: null,
|
|
83
|
+
...(noise ? { noise } : {}), message_key: `gitleaks:${f.RuleID}`, rule_version: ruleVersion, evidence: redacted, mapped_by: m.mapped_by, unmapped: !m.sec_ids.length,
|
|
84
|
+
secret: { redacted, fingerprint: relFingerprint(f.Fingerprint, o.repo), source, commit: f.Commit || null, tracked: isTracked },
|
|
85
|
+
};
|
|
86
|
+
}),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
// The fingerprint is [commit:]file:rule:line; make its file part repository relative too.
|
|
90
|
+
const relFingerprint = (fp, repo) => fp.includes(repo) ? fp.split(repo.replace(/\/$/, '') + '/').join('') : fp;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Runs the scanner adapters and writes, per tool, scan/<tool>.json (HitsFile), scan/<tool>.meta.json (ToolMeta) and
|
|
2
|
+
// scan/<tool>.detail.json (DetailFile). Nothing is installed. A missing tool, a tool that needs the network when the
|
|
3
|
+
// network is not allowed, a tool with nothing to read, and a tool that fails are each recorded with the reason.
|
|
4
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { cmp, stableJson } from '../util.js';
|
|
8
|
+
import { readHeadCommit } from '../discover/git.js';
|
|
9
|
+
import { loadControls, notInControls } from '../mapping/controls.js';
|
|
10
|
+
import { detectVersion, exec, readTracked, RULES_DIR, toolEnv, versionTested } from './common.js';
|
|
11
|
+
import { adapter as semgrep } from './semgrep.js';
|
|
12
|
+
import { adapter as gitleaks } from './gitleaks.js';
|
|
13
|
+
import { adapter as osv } from './osv-scanner.js';
|
|
14
|
+
import { adapter as trivy } from './trivy.js';
|
|
15
|
+
export const ADAPTERS = [semgrep, gitleaks, osv, trivy];
|
|
16
|
+
const hitKey = (h) => [h.file, String(h.line).padStart(9, '0'), h.tool, h.rule_id];
|
|
17
|
+
const byKey = (a, b) => { for (let i = 0; i < a.length; i++) {
|
|
18
|
+
const c = cmp(a[i], b[i]);
|
|
19
|
+
if (c)
|
|
20
|
+
return c;
|
|
21
|
+
} return 0; };
|
|
22
|
+
export function toContractHits(details) {
|
|
23
|
+
const seen = new Map();
|
|
24
|
+
for (const d of details) {
|
|
25
|
+
const h = { tool: d.tool, rule_id: d.rule_id, file: d.file, line: d.line, sec_ids: d.sec_ids, severity_hint: d.severity_hint, message_key: d.message_key, ...(d.noise ? { noise: d.noise } : {}) };
|
|
26
|
+
const k = stableJson(h);
|
|
27
|
+
if (!seen.has(k))
|
|
28
|
+
seen.set(k, h);
|
|
29
|
+
}
|
|
30
|
+
return [...seen.values()].sort((a, b) => byKey(hitKey(a), hitKey(b)) || cmp(stableJson(a), stableJson(b)));
|
|
31
|
+
}
|
|
32
|
+
const sortDetails = (xs) => xs.map((d) => [d, stableJson(d)]).sort((a, b) => byKey(hitKey(a[0]), hitKey(b[0])) || cmp(a[1], b[1])).map((x) => x[0]);
|
|
33
|
+
export function runScanner(a, opts) {
|
|
34
|
+
const started = Date.now();
|
|
35
|
+
const o = { ...opts, rulesDir: opts.rulesDir ?? RULES_DIR };
|
|
36
|
+
const env0 = toolEnv(o.env ?? process.env, o.allowNetwork);
|
|
37
|
+
const controls = loadControls(o.controlsPath);
|
|
38
|
+
const tracked = readTracked(o.repo);
|
|
39
|
+
const version = detectVersion(a.versionCommand, env0);
|
|
40
|
+
let status = 'ran';
|
|
41
|
+
let reason = null;
|
|
42
|
+
let res = null;
|
|
43
|
+
if (!version) {
|
|
44
|
+
status = 'skipped: not installed';
|
|
45
|
+
reason = `${a.tool} is not installed on this machine (not found on PATH). The engine does not install tools.`;
|
|
46
|
+
}
|
|
47
|
+
else if (a.needsNetwork && !o.allowNetwork) {
|
|
48
|
+
status = 'skipped: network not allowed';
|
|
49
|
+
reason = `${a.tool} sends package names and versions to osv.dev and needs its advisory data. Run with --allow-network to include it.`;
|
|
50
|
+
}
|
|
51
|
+
else if (!a.applies(o.stack)) {
|
|
52
|
+
status = 'skipped: not applicable';
|
|
53
|
+
reason = a.notApplicableReason(o);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
const scratch = mkdtempSync(join(tmpdir(), 'hullproof-scan-'));
|
|
57
|
+
const env = {
|
|
58
|
+
version, tracked, rulesDir: o.rulesDir, scratch,
|
|
59
|
+
run: (argv) => exec(argv, o.repo, env0),
|
|
60
|
+
};
|
|
61
|
+
try {
|
|
62
|
+
res = a.execute(o, env);
|
|
63
|
+
if (res && o.skipFiles?.size)
|
|
64
|
+
res = { ...res, hits: res.hits.filter((h) => !o.skipFiles.has(h.file)) };
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
status = 'failed';
|
|
68
|
+
reason = `${a.tool} did not finish: ${e.message}`;
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const details = sortDetails(res?.hits ?? []);
|
|
75
|
+
const scanned = res?.scanned ?? null;
|
|
76
|
+
const scannedSet = new Set(scanned ?? []);
|
|
77
|
+
const files = {
|
|
78
|
+
scanned: scanned ? scanned.length : null,
|
|
79
|
+
tracked: tracked ? tracked.size : null,
|
|
80
|
+
scanned_not_tracked: scanned && tracked ? scanned.filter((f) => !tracked.has(f)).length : null,
|
|
81
|
+
tracked_not_scanned: scanned && tracked ? [...tracked].filter((f) => !scannedSet.has(f)).length : null,
|
|
82
|
+
};
|
|
83
|
+
const ran = status === 'ran';
|
|
84
|
+
const meta = {
|
|
85
|
+
schema_version: '1.0.0', tool: a.id, status, version, tested_version_range: a.testedVersions,
|
|
86
|
+
version_tested: versionTested(version, a.testedVersions), argv: res?.commands[0] ?? [], config_sha256: res?.configSha256 ?? null,
|
|
87
|
+
commit: readHeadCommit(o.repo), files_scanned: files.scanned, files_tracked: files.tracked, exit_code: res?.exitCode ?? null,
|
|
88
|
+
db_date: res?.dbDate ?? null, not_assessed_sec_ids: ran ? [] : a.possibleSecIds(o),
|
|
89
|
+
};
|
|
90
|
+
const detail = {
|
|
91
|
+
schema_version: '0.1.0', tool: a.id, status, not_run_reason: reason, tool_version: version, commands: res?.commands ?? [],
|
|
92
|
+
files, as_of: res?.asOf ?? null, policy: res?.policy ?? null,
|
|
93
|
+
sec_ids_not_in_controls: [...new Set(notInControls(details.flatMap((d) => [...d.sec_ids, ...(d.advisory?.mapped_sec_ids ?? [])]), controls))].sort(cmp),
|
|
94
|
+
tool_errors: (res?.toolErrors ?? []).sort((x, y) => cmp(stableJson(x), stableJson(y))),
|
|
95
|
+
hits: details,
|
|
96
|
+
};
|
|
97
|
+
return { hits: { schema_version: '1.0.0', tool: a.id, hits: toContractHits(details) }, meta, detail, seconds: (Date.now() - started) / 1000 };
|
|
98
|
+
}
|
|
99
|
+
export function writeScan(outDir, s) {
|
|
100
|
+
const dir = join(outDir, 'scan');
|
|
101
|
+
mkdirSync(dir, { recursive: true });
|
|
102
|
+
writeFileSync(join(dir, `${s.meta.tool}.json`), stableJson(s.hits));
|
|
103
|
+
writeFileSync(join(dir, `${s.meta.tool}.meta.json`), stableJson(s.meta));
|
|
104
|
+
writeFileSync(join(dir, `${s.meta.tool}.detail.json`), stableJson(s.detail));
|
|
105
|
+
}
|
|
106
|
+
// Runs every adapter (or the ones named) and writes their files. Run times go to scan/run.json, outside the contract.
|
|
107
|
+
export function runScanners(opts, tools) {
|
|
108
|
+
const out = ADAPTERS.filter((a) => !tools || tools.includes(a.id)).map((a) => runScanner(a, opts));
|
|
109
|
+
for (const s of out)
|
|
110
|
+
writeScan(opts.out, s);
|
|
111
|
+
writeFileSync(join(opts.out, 'scan', 'run.json'), JSON.stringify({ seconds: Object.fromEntries(out.map((s) => [s.meta.tool, s.seconds])) }, null, 2) + '\n');
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// OSV Scanner adapter. It sends package names and versions to osv.dev, so it runs only with --allow-network; without it
|
|
2
|
+
// the tool is NOT RUN with that reason. One hit per advisory group per package, with the advisory id, package, version,
|
|
3
|
+
// severity class, fixed versions and the dependency policy decision. The result is reproducible only for the same
|
|
4
|
+
// advisory data date, recorded as db_date.
|
|
5
|
+
import { cmp, sortUnique } from '../util.js';
|
|
6
|
+
import { relPath } from './common.js';
|
|
7
|
+
import { inEdition, loadControls } from '../mapping/controls.js';
|
|
8
|
+
import { mapHit, tableSecIds } from '../mapping/map.js';
|
|
9
|
+
import { classFromScore, classFromWord, decide, DEFAULT_POLICY } from '../mapping/dependency-policy.js';
|
|
10
|
+
const RANK = { UNKNOWN: 0, LOW: 1, MEDIUM: 2, HIGH: 3, CRITICAL: 4 };
|
|
11
|
+
export const today = () => new Date().toISOString().slice(0, 10);
|
|
12
|
+
export const adapter = {
|
|
13
|
+
id: 'osv-scanner',
|
|
14
|
+
tool: 'osv-scanner',
|
|
15
|
+
versionCommand: ['osv-scanner', '--version'],
|
|
16
|
+
testedVersions: '2.6.x',
|
|
17
|
+
needsNetwork: true,
|
|
18
|
+
applies: (stack) => stack.package_managers.length > 0,
|
|
19
|
+
notApplicableReason: (o) => (adapter.applies(o.stack) ? null : 'No package manifest or lockfile was found, so there are no dependencies to check.'),
|
|
20
|
+
configFiles: () => [],
|
|
21
|
+
command: () => ['osv-scanner', 'scan', 'source', '--format', 'json', '--recursive', '.'],
|
|
22
|
+
parse: (stdout, ctx) => parseOsv(stdout, ctx, ctx.asOf ?? today()).hits,
|
|
23
|
+
possibleSecIds: (o) => sortUnique(inEdition(tableSecIds('osv-scanner'), o.edition, loadControls(o.controlsPath))),
|
|
24
|
+
execute(o, env) {
|
|
25
|
+
const asOf = o.asOf ?? today();
|
|
26
|
+
const argv = adapter.command(o);
|
|
27
|
+
const r = env.run(argv);
|
|
28
|
+
// 0: no advisories, 1: advisories found, 128: no packages found. Anything else is a failure.
|
|
29
|
+
if (r.status !== 0 && r.status !== 1 && r.status !== 128)
|
|
30
|
+
throw new Error(`osv-scanner exited with ${r.status ?? r.error}`);
|
|
31
|
+
const out = r.status === 128 ? { hits: [], scanned: [] } : parseOsv(r.stdout, o, asOf);
|
|
32
|
+
return { hits: out.hits, commands: [argv], configSha256: null, scanned: out.scanned, exitCode: r.status, toolErrors: [], dbDate: asOf, asOf, policy: o.policy ?? DEFAULT_POLICY };
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
export function parseOsv(stdout, o, asOf) {
|
|
36
|
+
const controls = loadControls(o.controlsPath);
|
|
37
|
+
const policy = o.policy ?? DEFAULT_POLICY;
|
|
38
|
+
const j = JSON.parse(stdout);
|
|
39
|
+
const hits = [];
|
|
40
|
+
const scanned = new Set();
|
|
41
|
+
for (const res of j.results ?? []) {
|
|
42
|
+
const file = relPath(res.source.path, o.repo);
|
|
43
|
+
scanned.add(file);
|
|
44
|
+
for (const p of res.packages ?? []) {
|
|
45
|
+
for (const g of p.groups ?? []) {
|
|
46
|
+
const ids = [...g.ids].sort(cmp);
|
|
47
|
+
const id = ids.find((x) => x.startsWith('GHSA-')) ?? ids[0];
|
|
48
|
+
const vulns = (p.vulnerabilities ?? []).filter((v) => ids.includes(v.id));
|
|
49
|
+
const score = Number.parseFloat(g.max_severity ?? '');
|
|
50
|
+
const cls = score > 0 ? classFromScore(score)
|
|
51
|
+
: vulns.map((v) => classFromWord(v.database_specific?.severity)).reduce((a, b) => (RANK[b] > RANK[a] ? b : a), 'UNKNOWN');
|
|
52
|
+
const dates = vulns.map((v) => v.published?.slice(0, 10)).filter((d) => !!d).sort(cmp);
|
|
53
|
+
const published = dates[0] ?? null;
|
|
54
|
+
const fixed = sortUnique(vulns.flatMap((v) => (v.affected ?? [])
|
|
55
|
+
.filter((a) => a.package?.name === p.package.name)
|
|
56
|
+
.flatMap((a) => (a.ranges ?? []).flatMap((rg) => (rg.events ?? []).map((e) => e.fixed).filter((x) => !!x)))));
|
|
57
|
+
const d = decide(cls, published, asOf, policy);
|
|
58
|
+
const version = p.package.version || p.package.commit || '';
|
|
59
|
+
const mapped = mapHit({ tool: 'osv-scanner', rule_id: id, file }, o.edition, controls);
|
|
60
|
+
const fails = d.decision === 'fail';
|
|
61
|
+
const modified = vulns.map((v) => v.modified ?? '').sort(cmp).pop() || 'unknown';
|
|
62
|
+
hits.push({
|
|
63
|
+
tool: 'osv-scanner', rule_id: id, file, line: 0, end_line: 0, sec_ids: fails ? mapped.sec_ids : [],
|
|
64
|
+
severity_hint: cls, message_key: `osv-scanner:${id}`, rule_version: `advisory modified ${modified}`,
|
|
65
|
+
evidence: `${p.package.name}@${version} (${p.package.ecosystem})`,
|
|
66
|
+
mapped_by: fails ? mapped.mapped_by : 'policy report only', unmapped: fails && !mapped.sec_ids.length,
|
|
67
|
+
advisory: {
|
|
68
|
+
id, aliases: sortUnique([...(g.aliases ?? []), ...ids].filter((x) => x !== id)), package: p.package.name,
|
|
69
|
+
ecosystem: p.package.ecosystem, version, severity_class: cls, cvss_score: score > 0 ? score : null,
|
|
70
|
+
fixed_versions: fixed, published, ...d, mapped_sec_ids: mapped.sec_ids,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { hits, scanned: [...scanned].sort(cmp) };
|
|
77
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Development tool. Runs every adapter twice on each target folder, writing into two
|
|
2
|
+
// temporary output folders (never into the target), and prints per tool: status, version, hits, mapped and unmapped
|
|
3
|
+
// hits, files scanned and tracked, run time of each run, and whether both runs gave the same bytes.
|
|
4
|
+
// Usage: node src/scanners/run-targets.ts [--allow-network] [--history] DIR...
|
|
5
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { basename, join, resolve } from 'node:path';
|
|
8
|
+
import { discover } from '../discover/discover.js';
|
|
9
|
+
import { sha256 } from './common.js';
|
|
10
|
+
import { runScanners } from './index.js';
|
|
11
|
+
const args = process.argv.slice(2);
|
|
12
|
+
const allowNetwork = args.includes('--allow-network');
|
|
13
|
+
const history = args.includes('--history');
|
|
14
|
+
const rows = [];
|
|
15
|
+
for (const target of args.filter((a) => !a.startsWith('--')).map((a) => resolve(a))) {
|
|
16
|
+
const stack = discover(target);
|
|
17
|
+
const outs = [0, 1].map(() => mkdtempSync(join(tmpdir(), 'hullproof-runs-')));
|
|
18
|
+
const runs = outs.map((out) => runScanners({ repo: target, out, stack, allowNetwork, edition: 'pro', history }));
|
|
19
|
+
for (const [i, s] of runs[0].entries()) {
|
|
20
|
+
const files = ['json', 'meta.json', 'detail.json'].map((ext) => `scan/${s.meta.tool}.${ext}`);
|
|
21
|
+
const same = files.every((f) => sha256(readFileSync(join(outs[0], f))) === sha256(readFileSync(join(outs[1], f))));
|
|
22
|
+
const hits = s.detail.hits;
|
|
23
|
+
rows.push({
|
|
24
|
+
target: basename(target), tool: s.meta.tool, status: s.meta.status, version: s.meta.version, reason: s.detail.not_run_reason,
|
|
25
|
+
hits: s.hits.hits.length, detail_hits: hits.length, mapped: hits.filter((h) => h.sec_ids.length).length,
|
|
26
|
+
unmapped: hits.filter((h) => h.unmapped).length, report_only: hits.filter((h) => h.mapped_by === 'policy report only').length,
|
|
27
|
+
files: s.detail.files, tool_errors: s.detail.tool_errors.length, seconds: [runs[0][i].seconds, runs[1][i].seconds],
|
|
28
|
+
identical: same, sha256: sha256(readFileSync(join(outs[0], files[0]))).slice(0, 12),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
for (const o of outs)
|
|
32
|
+
rmSync(o, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Secret hits that are almost surely not a live secret: a placeholder or a published example value, or a shapeless rule
|
|
2
|
+
// (a generic key, a curl header, a bare JWT) matching in documentation, fixture data, an example env file, a lockfile,
|
|
3
|
+
// vendored or generated code, or a migration. The hit is kept and reported as an example value; it does not hold the gate.
|
|
4
|
+
//
|
|
5
|
+
// What stays held, on purpose: a rule with a vendor shape (AWS, GitHub, Stripe, a private key block and so on) anywhere
|
|
6
|
+
// except where its value is a placeholder, and every rule in source and config files and in test source files.
|
|
7
|
+
// Rules with no vendor shape: they match on a variable name or a loose pattern, so a path says more than the value does.
|
|
8
|
+
export const WEAK_RULES = new Set(['generic-api-key', 'curl-auth-header', 'curl-auth-user', 'jwt', 'hullproof-url-with-password']);
|
|
9
|
+
const FOLDERS = (s) => new Set(s.split(' '));
|
|
10
|
+
const DOC_FOLDERS = FOLDERS('docs doc documentation api-docs apidocs');
|
|
11
|
+
const DOC_EXT = /\.(?:md|mdx|rst|adoc|txt|html?|ya?ml|json)$/i;
|
|
12
|
+
const DOC_FILE = /\.(?:md|mdx|rst|adoc)$|^(?:readme|changelog|changes|history|contributing|license|notice)(?:\.\w+)?$/i;
|
|
13
|
+
const FIXTURE_FOLDERS = FOLDERS('fixtures fixture __fixtures__ testdata test-data test_data testfixtures __snapshots__ cassettes');
|
|
14
|
+
const TEST_FOLDERS = FOLDERS('test tests __tests__ spec specs e2e cypress');
|
|
15
|
+
const DATA_EXT = /\.(?:json|ya?ml|txt|csv|xml|toml|snap|har|md)$/i;
|
|
16
|
+
const MIGRATION_FOLDERS = FOLDERS('migrations migrate alembic');
|
|
17
|
+
const VENDOR_FOLDERS = FOLDERS('vendor vendors third_party third-party node_modules bower_components site-packages pods plugins webjars');
|
|
18
|
+
const LOCKFILES = /^(?:package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|cargo\.lock|composer\.lock|poetry\.lock|pipfile\.lock|gemfile\.lock|go\.sum|packages\.lock\.json|gradle\.lockfile|mix\.lock|pubspec\.lock|podfile\.lock|flake\.lock)$/i;
|
|
19
|
+
const EXAMPLE_FILE = /(?:^|\/)\.env\.(?:example|sample|template|dist|tpl)$|\.(?:example|sample|template|dist|tpl)$|\.(?:example|sample|template)\.\w+$/i;
|
|
20
|
+
const GENERATED = /(?:\.min\.(?:js|css)|\.pb\.go|_pb2\.py|\.g\.dart|\.generated\.\w+|_generated\.\w+|\.snap)$/i;
|
|
21
|
+
// Why a repository relative path makes a shapeless rule's hit an example value, or null.
|
|
22
|
+
export function pathNoise(file) {
|
|
23
|
+
const parts = file.split('/');
|
|
24
|
+
const name = parts.pop() ?? '';
|
|
25
|
+
const dirs = parts.map((p) => p.toLowerCase());
|
|
26
|
+
const has = (set) => parts.find((p) => set.has(p.toLowerCase()));
|
|
27
|
+
if (LOCKFILES.test(name))
|
|
28
|
+
return 'lockfile';
|
|
29
|
+
if (DOC_FILE.test(name))
|
|
30
|
+
return 'documentation file';
|
|
31
|
+
if (EXAMPLE_FILE.test(file))
|
|
32
|
+
return 'example or sample file';
|
|
33
|
+
const doc = has(DOC_FOLDERS);
|
|
34
|
+
if (doc && DOC_EXT.test(name))
|
|
35
|
+
return `documentation folder ${doc}/`;
|
|
36
|
+
const fx = has(FIXTURE_FOLDERS);
|
|
37
|
+
if (fx)
|
|
38
|
+
return `fixture data folder ${fx}/`;
|
|
39
|
+
if (/\.(?:fixtures?)\.\w+$/i.test(name))
|
|
40
|
+
return 'fixture file';
|
|
41
|
+
const t = has(TEST_FOLDERS);
|
|
42
|
+
if (t && DATA_EXT.test(name))
|
|
43
|
+
return `test data in ${t}/`;
|
|
44
|
+
const mg = has(MIGRATION_FOLDERS) ?? (dirs.includes('db') && dirs.includes('migrate') ? 'migrate' : undefined);
|
|
45
|
+
if (mg)
|
|
46
|
+
return `migration folder ${mg}/`;
|
|
47
|
+
const vd = has(VENDOR_FOLDERS);
|
|
48
|
+
if (vd)
|
|
49
|
+
return `vendored code in ${vd}/`;
|
|
50
|
+
if (GENERATED.test(name) || dirs.includes('__generated__'))
|
|
51
|
+
return 'generated file';
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
// Values that are published examples, never a credential.
|
|
55
|
+
const KNOWN_EXAMPLES = [
|
|
56
|
+
'AKIAIOSFODNN7EXAMPLE', 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', 'ASIAIOSFODNN7EXAMPLE',
|
|
57
|
+
// The jwt.io sample token (John Doe, iat 1516239022).
|
|
58
|
+
'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ',
|
|
59
|
+
];
|
|
60
|
+
const PLACEHOLDER = /^(?:(.)\1{5,}|(?:0123456789|1234567890|12345678|abcdef|abcdefgh)[\w-]*|.*(?:example|your[-_.]|changeme|change[-_]me|placeholder|dummy|sample|redacted|fake|insert|replace|todo)[\w-]*|x{4,}[\w-]*|\*{4,})$/i;
|
|
61
|
+
const GUID_SEQ = /^(?:12345678-1234-1234-1234-1234567890ab|12345678-1234-1234-1234-123456789abc|00000000-0000-0000-0000-000000000000)$/i;
|
|
62
|
+
// The values on a line: the text inside quotes (after a "name:" or "name=" prefix if it has one), or else what follows a : or =.
|
|
63
|
+
// A line with no such value is never judged a placeholder, so a short real password beside a variable named EXAMPLE_PASSWORD still holds.
|
|
64
|
+
function valuesOf(line) {
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const m of line.matchAll(/(["'`])((?:(?!\1).){4,})\1/g))
|
|
67
|
+
out.push(m[2].replace(/^.*?[:=]\s*(?=\S)/, (pre) => (/^[\w.-]+\s*[:=]\s*$/.test(pre) ? '' : pre)).trim());
|
|
68
|
+
if (!out.length)
|
|
69
|
+
for (const m of line.matchAll(/[:=]\s*([^\s"'`,;]{4,})/g))
|
|
70
|
+
out.push(m[1]);
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
// A line holds a placeholder when it has values and every one is placeholder shaped or a known example. The secret itself is
|
|
74
|
+
// redacted by the scanner, so the line is the only place the value can be judged.
|
|
75
|
+
export function valueNoise(line) {
|
|
76
|
+
if (KNOWN_EXAMPLES.some((k) => line.includes(k)))
|
|
77
|
+
return 'published example value';
|
|
78
|
+
const vs = valuesOf(line);
|
|
79
|
+
if (!vs.length)
|
|
80
|
+
return null;
|
|
81
|
+
return vs.every((v) => PLACEHOLDER.test(v) || GUID_SEQ.test(v) || /^(?:<[\w\s.-]+>|\$\{[^}]+\}|\{\{[^}]+\}\}|\$\w+)$/.test(v)) ? 'placeholder value' : null;
|
|
82
|
+
}
|
|
83
|
+
// The reason a Gitleaks hit is an example value, or null when it must hold. `line` is the source line, or null when it is not known.
|
|
84
|
+
export function secretNoise(rule, file, line) {
|
|
85
|
+
const v = line === null ? null : valueNoise(line);
|
|
86
|
+
if (v)
|
|
87
|
+
return v;
|
|
88
|
+
return WEAK_RULES.has(rule) ? pathNoise(file) : null;
|
|
89
|
+
}
|