Wuhp commited on
Commit
72f5723
·
1 Parent(s): 14929e4

Use native TruffleHog verification across all built-in detectors

Browse files
README.md CHANGED
@@ -7,15 +7,17 @@ app_port: 7860
7
 
8
  ## Optional TruffleHog verification
9
 
10
- In **Settings → Module inputs**, supply source text and select **Verify found credentials I own or am authorized to test**. Select TruffleHog in the pipeline as usual. Verification is off by default, is not enabled by Full pipeline, and resets after a successful scan submission. No separate provider API key is required: verification authenticates with the detected credential from the HF worker to its provider.
11
 
12
- All detector types still run in a detection-only first pass. A second pass checks up to 10 distinct candidates within a shared 30-second budget (one worker, up to 12 seconds per candidate). The pinned native verifiers are restricted to AWS access-key pairs, GitHub, Slack, Hugging Face and OpenAI. Each candidate gets its own temporary input to prevent cross-pairing; arbitrary-host/database/private-key verifiers, custom verifier URLs, account/resource analysis and known AWS canaries are excluded. AWS canary verification uses a different upstream operation and remains detection-only. Other types and candidates outside the budget remain visible.
13
 
14
- Findings are grouped by detector and result: **Verified** (provider authentication confirmed; high confidence), **Unverified** (TruffleHog did not confirm validity), **Inconclusive** (provider/transport error, missing output or timeout), or **Detection-only** (not attempted). Unverified does not prove a secret is invalid. Inconclusive or exhausted-budget verification marks the stage partial. Job logs include only verification counts and budget diagnostics; detection findings survive verification failures.
15
 
16
- Secret values, source snippets, identity metadata and provider error text are never returned in results or logs. Source and candidate files are temporary on HF and removed after use. API clients opt in with `options.inputs.trufflehog_verify: true` alongside `source_text`. Only a boolean, source byte count and sanitized result counts are persisted.
17
 
18
- Implementation follows the [pinned TruffleHog documentation](https://github.com/trufflesecurity/trufflehog/blob/v3.99.7/README.md) and its provider/JSON output implementations. GitHub-hosted tests mock verification responses; the installed-binary smoke remains explicitly `--no-verification` and does not use real credentials.
 
 
19
 
20
  ## Direct URLs and target-list imports
21
 
 
7
 
8
  ## Optional TruffleHog verification
9
 
10
+ In **Settings → Module inputs**, supply source text and select **Verify found credentials I own or am authorized to test**. Select TruffleHog in the pipeline as usual. Verification is off by default, is not enabled by Full pipeline, and resets after a successful scan submission. No separate provider API key is required: native detectors use detected credentials to check the relevant service from the HF worker.
11
 
12
+ The adapter runs one native `trufflehog filesystem` scan against the **original source**, with verification enabled by omitting `--no-verification`. All built-in detector types are available: there is no application provider allowlist, per-credential source reconstruction, extra verification pass or 10-candidate cap. TruffleHog handles credential pairing, endpoint/context selection, verification support, caching, overlap decisions and provider requests. This can contact a database or host identified in source, rather than only one of five public providers. The app does not request separate resource/permission analysis or supply custom verifier URLs.
13
 
14
+ For HF, the process uses one worker and a 120-second overall deadline (40 seconds when verification is off). Deadlines retain parsed partial output and remain timeout results, never clean scans. No source archive extraction or binary scanning is enabled. Native detection/verification does not imply every credential can be verified successfully: detector support, service reachability and provider restrictions still apply.
15
 
16
+ Findings preserve native **Verified**, **Unverified** and **Inconclusive** (verification error) outcomes. Disabled or explicitly skipped verification is **Detection-only**. The pinned native JSON does not always distinguish a skipped/unsupported verifier from an unverified credential, so unverified must never be interpreted as invalid or as proof a request was made. Error results mark an otherwise completed stage partial. Logs report native mode, deadline and result counts; they do not invent a count of network verification attempts.
17
 
18
+ Secret values, source snippets, identity metadata and provider error text are never returned in results or logs. Source files are temporary on HF and removed after use. API clients opt in with `options.inputs.trufflehog_verify: true` alongside `source_text`. Only the setting, source byte count and sanitized result counts are persisted.
19
+
20
+ This uses the [pinned TruffleHog verification behavior](https://github.com/trufflesecurity/trufflehog/blob/v3.99.7/README.md). GitHub-hosted tests mock native verification output; the installed-binary smoke remains explicitly `--no-verification` and does not authenticate real credentials.
21
 
22
  ## Direct URLs and target-list imports
23
 
app/final_modules.py CHANGED
@@ -4,7 +4,6 @@ import base64
4
  import os
5
  import re
6
  import tempfile
7
- import time
8
  from pathlib import Path
9
  from urllib.parse import urljoin, urlsplit
10
 
@@ -44,15 +43,27 @@ def json_rows(rows):
44
  return found
45
 
46
 
47
- # Audited pinned v3.99.7 provider checks; never enable arbitrary-host/DB verifiers.
48
- TRUFFLEHOG_VERIFIERS = frozenset({"AWS", "Github", "Slack", "HuggingFace", "OpenAI"})
49
- TRUFFLEHOG_VERIFY_LIMIT = 10
50
- TRUFFLEHOG_VERIFY_SECONDS = 30
 
 
 
 
 
 
 
 
 
 
 
51
 
52
 
53
  def trufflehog_candidates(rows):
54
  # Keys and native metadata remain in memory only, never in returned diagnostics.
55
  candidates = {}
 
56
  for row in json_rows(rows):
57
  name = row.get("DetectorName")
58
  if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,79}", name):
@@ -60,80 +71,29 @@ def trufflehog_candidates(rows):
60
  raw = row.get("RawV2") or row.get("Raw") or ""
61
  if not isinstance(raw, str):
62
  continue
63
- candidates.setdefault((name, raw), row)
 
 
 
64
  return candidates
65
 
66
 
67
- def verification_source(name, row):
68
- raw = row.get("Raw")
69
- if not isinstance(raw, str) or not raw or len(raw) > 8000 or any(c in raw for c in "\r\n\x00"):
70
- return None
71
- if name == "AWS":
72
- # The upstream canary verifier uses SNS Publish. Do not invoke it.
73
- extra = row.get("ExtraData")
74
- if isinstance(extra, dict) and str(extra.get("is_canary", "")).lower() == "true":
75
- return None
76
- pair = row.get("RawV2", "")
77
- if not isinstance(pair, str) or not re.fullmatch(r"(?:AKIA|ABIA|ACCA)[A-Z0-9]{16}:[A-Za-z0-9/+=]{40}", pair):
78
- return None
79
- return pair.replace(":", "\n", 1)
80
- # Preserve the context needed by GitHub's legacy 40-character token detector.
81
- return ("github_token = " if name == "Github" else "") + raw
82
-
83
 
84
- async def verify_trufflehog(rows, root):
85
- candidates = trufflehog_candidates(rows)
86
- states = {key: "not_attempted" for key in candidates}
87
- attempted = 0
88
- started = time.monotonic()
89
- directory = root / "verification"
90
- directory.mkdir(mode=0o700)
91
- for key, row in candidates.items():
92
- name, _ = key
93
- text = verification_source(name, row) if name in TRUFFLEHOG_VERIFIERS else None
94
- remaining = TRUFFLEHOG_VERIFY_SECONDS - (time.monotonic() - started)
95
- if text is None or attempted >= TRUFFLEHOG_VERIFY_LIMIT or remaining < 1:
96
- continue
97
- path = directory / "candidate.txt"
98
- path.write_text(text, encoding="utf-8")
99
- path.chmod(0o600)
100
- args = ["/usr/local/bin/trufflehog", "filesystem", str(path), "--json",
101
- "--include-detectors", name, "--results", "verified,unverified,unknown",
102
- "--allow-verification-overlap", "--no-verification-cache", "--no-update",
103
- "--concurrency", "1", "--force-skip-binaries", "--force-skip-archives",
104
- "--fail-on-scan-errors"]
105
- attempted += 1
106
- try:
107
- verified_rows, status = await execute(args, min(12, max(1, int(remaining))))
108
- except Exception:
109
- # Executor failures must preserve detection results without persisting exception text.
110
- verified_rows, status = [], "failed"
111
- finally:
112
- path.unlink(missing_ok=True)
113
- matches = [item for candidate, item in trufflehog_candidates(verified_rows).items() if candidate == key]
114
- # Missing output, timeout and transport/provider errors never mean invalid.
115
- state = "unknown"
116
- if any(item.get("Verified") is True for item in matches):
117
- state = "verified"
118
- elif status == "completed" and matches and all(
119
- item.get("Verified") is False and not item.get("VerificationError") for item in matches):
120
- state = "unverified"
121
- states[key] = state
122
- return states, attempted
123
 
124
-
125
- def parse_trufflehog(rows, url, states=None):
126
  # Never return Raw, RawV2, Redacted, ExtraData, errors or source snippets.
127
  groups = {}
128
- for key in trufflehog_candidates(rows):
129
- name, _ = key
130
- state = (states or {}).get(key, "not_attempted")
131
  groups[(name, state)] = groups.get((name, state), 0) + 1
132
  descriptions = {
133
- "verified": "Provider authentication confirmed this credential at scan time.",
134
- "unverified": "TruffleHog did not confirm validity. This does not prove the credential is invalid.",
135
- "unknown": "Verification was inconclusive, failed or timed out; validity is unknown.",
136
- "not_attempted": "Detection only; verification was disabled, unsupported, excluded or outside the verification budget.",
 
137
  }
138
  findings = []
139
  for (name, state), count in groups.items():
@@ -227,10 +187,15 @@ async def run(module, url, host, agent, delay, inputs=None, profile="current"):
227
  (source / "source.txt").write_text(values.source_text, encoding="utf-8")
228
  (source / "source.txt").chmod(0o600)
229
  args = ["/usr/local/bin/trufflehog", "filesystem", str(source), "--json",
230
- "--no-verification", "--no-update", "--concurrency", "1",
231
  "--force-skip-binaries", "--force-skip-archives", "--fail-on-scan-errors"]
232
- deadline = 40
233
- reason = "Supplied source only; secret verification disabled; values and context redacted"
 
 
 
 
 
234
  elif module in ("trivy", "depscan", "osv-scanner"):
235
  sbom = root / "bom.json"
236
  sbom.write_text(values.sbom_text, encoding="utf-8")
@@ -292,26 +257,17 @@ async def run(module, url, host, agent, delay, inputs=None, profile="current"):
292
  raise ValueError("Unsupported module")
293
  rows, status = await execute(args, deadline)
294
  if module == "trufflehog":
295
- states, attempted = {}, 0
296
- if values.trufflehog_verify:
297
- states, attempted = await verify_trufflehog(rows, root)
298
- deadline += TRUFFLEHOG_VERIFY_SECONDS
299
- findings = parse_trufflehog(rows, url, states)
300
- candidates = trufflehog_candidates(rows)
301
- eligible = sum(name in TRUFFLEHOG_VERIFIERS and verification_source(name, row) is not None
302
- for (name, _), row in candidates.items())
303
- verification = {"enabled": values.trufflehog_verify, "attempted": attempted,
304
- "eligible": eligible,
305
- "budget_limited": max(0, eligible - attempted) if values.trufflehog_verify else 0,
306
- "limit": TRUFFLEHOG_VERIFY_LIMIT, "budget_seconds": TRUFFLEHOG_VERIFY_SECONDS,
307
- **{state: sum(states.get(key, "not_attempted") == state for key in candidates)
308
  for state in ("verified", "unverified", "unknown", "not_attempted")}}
309
  if values.trufflehog_verify:
310
- reason = ("Provider verification enabled; up to 10 candidates / 30s; " +
311
- ", ".join(f"{verification[state]} {state.replace('_', ' ')}"
312
- for state in ("verified", "unverified", "unknown", "not_attempted")) +
313
- "; values, identity metadata and provider errors redacted")
314
- if (verification["unknown"] or verification["budget_limited"]) and status == "completed":
315
  status = "partial"
316
  elif module == "amass":
317
  path = root / "hosts.txt"
 
4
  import os
5
  import re
6
  import tempfile
 
7
  from pathlib import Path
8
  from urllib.parse import urljoin, urlsplit
9
 
 
43
  return found
44
 
45
 
46
+ # Let the pinned native tool choose and run its built-in detector verifiers.
47
+ TRUFFLEHOG_DETECTION_SECONDS = 40
48
+ TRUFFLEHOG_VERIFY_SECONDS = 120
49
+
50
+
51
+ def native_secret_state(row):
52
+ if row.get("Verified") is True:
53
+ return "verified"
54
+ if row.get("VerificationSkipped") is True:
55
+ return "not_attempted"
56
+ if row.get("VerificationError") or not isinstance(row.get("Verified"), bool):
57
+ return "unknown"
58
+ # Native false includes unsupported/skipped verification in versions whose
59
+ # JSON does not expose VerificationSkipped. Never describe it as invalid.
60
+ return "unverified"
61
 
62
 
63
  def trufflehog_candidates(rows):
64
  # Keys and native metadata remain in memory only, never in returned diagnostics.
65
  candidates = {}
66
+ rank = {"verified": 3, "unknown": 2, "unverified": 1, "not_attempted": 0}
67
  for row in json_rows(rows):
68
  name = row.get("DetectorName")
69
  if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,79}", name):
 
71
  raw = row.get("RawV2") or row.get("Raw") or ""
72
  if not isinstance(raw, str):
73
  continue
74
+ key = (name, raw)
75
+ previous = candidates.get(key)
76
+ if previous is None or rank[native_secret_state(row)] > rank[native_secret_state(previous)]:
77
+ candidates[key] = row
78
  return candidates
79
 
80
 
81
+ def trufflehog_states(rows, verification_enabled):
82
+ return {key: native_secret_state(row) if verification_enabled else "not_attempted"
83
+ for key, row in trufflehog_candidates(rows).items()}
 
 
 
 
 
 
 
 
 
 
 
 
 
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
 
86
+ def parse_trufflehog(rows, url, verification_enabled=False):
 
87
  # Never return Raw, RawV2, Redacted, ExtraData, errors or source snippets.
88
  groups = {}
89
+ for (name, _), state in trufflehog_states(rows, verification_enabled).items():
 
 
90
  groups[(name, state)] = groups.get((name, state), 0) + 1
91
  descriptions = {
92
+ "verified": "Native TruffleHog verification confirmed this credential at scan time.",
93
+ "unverified": ("Native TruffleHog did not confirm validity. The detector may not support "
94
+ "verification or may have skipped it; this does not prove the credential is invalid."),
95
+ "unknown": "Native verification was inconclusive or reported an error; validity is unknown.",
96
+ "not_attempted": "Detection only; verification was disabled or the native detector reported skipping it.",
97
  }
98
  findings = []
99
  for (name, state), count in groups.items():
 
187
  (source / "source.txt").write_text(values.source_text, encoding="utf-8")
188
  (source / "source.txt").chmod(0o600)
189
  args = ["/usr/local/bin/trufflehog", "filesystem", str(source), "--json",
190
+ "--results", "verified,unverified,unknown", "--no-update", "--concurrency", "1",
191
  "--force-skip-binaries", "--force-skip-archives", "--fail-on-scan-errors"]
192
+ if values.trufflehog_verify:
193
+ deadline = TRUFFLEHOG_VERIFY_SECONDS
194
+ reason = "Native TruffleHog verification enabled for all detectors; original supplied source; values and context redacted"
195
+ else:
196
+ args.append("--no-verification")
197
+ deadline = TRUFFLEHOG_DETECTION_SECONDS
198
+ reason = "Supplied source only; secret verification disabled; values and context redacted"
199
  elif module in ("trivy", "depscan", "osv-scanner"):
200
  sbom = root / "bom.json"
201
  sbom.write_text(values.sbom_text, encoding="utf-8")
 
257
  raise ValueError("Unsupported module")
258
  rows, status = await execute(args, deadline)
259
  if module == "trufflehog":
260
+ states = trufflehog_states(rows, values.trufflehog_verify)
261
+ findings = parse_trufflehog(rows, url, values.trufflehog_verify)
262
+ verification = {"enabled": values.trufflehog_verify, "mode": "native",
263
+ "detectors": "all", "deadline_seconds": deadline,
264
+ "candidates_reported": len(states),
265
+ **{state: sum(value == state for value in states.values())
 
 
 
 
 
 
 
266
  for state in ("verified", "unverified", "unknown", "not_attempted")}}
267
  if values.trufflehog_verify:
268
+ reason += "; " + ", ".join(f"{verification[state]} {state.replace('_', ' ')}"
269
+ for state in ("verified", "unverified", "unknown", "not_attempted"))
270
+ if verification["unknown"] and status == "completed":
 
 
271
  status = "partial"
272
  elif module == "amass":
273
  path = root / "hosts.txt"
app/index.html CHANGED
@@ -73,7 +73,7 @@ https://example.org/,Main app</pre><small>TSV uses tabs. Supported target header
73
  <label class="module-card" data-module-search="dalfox unverified xss review."><input id="modDalfox" type="checkbox" onchange="updatePipelinePreview()"/><span><strong>Dalfox</strong><small>Unverified XSS review.</small></span></label>
74
  <label class="module-card" data-module-search="sqlmap low-risk boolean tests; no data extraction."><input id="modSqlmap" type="checkbox" onchange="updatePipelinePreview()"/><span><strong>SQLmap</strong><small>Low-risk boolean tests; no data extraction.</small></span></label>
75
  <label class="module-card" data-module-search="garak two benign output-filter checks; rest llm path required."><input id="modGarak" type="checkbox" onchange="updatePipelinePreview()"/><span><strong>garak</strong><small>Two benign output-filter checks; REST LLM path required.</small></span></label></div></section>
76
- <section class="module-group"><h3>Source & dependencies</h3><div class="module-grid"><label class="module-card" data-module-search="trufflehog supplied source text; optional bounded provider verification."><input id="modTrufflehog" type="checkbox" onchange="updatePipelinePreview()"/><span><strong>TruffleHog</strong><small>Supplied source text; optional bounded provider verification.</small></span></label>
77
  <label class="module-card" data-module-search="dep-scan cyclonedx sbom; application-only database, 2024 onward."><input id="modDepscan" type="checkbox" onchange="updatePipelinePreview()"/><span><strong>dep-scan</strong><small>CycloneDX SBOM; application-only database, 2024 onward.</small></span></label>
78
  <label class="module-card" data-module-search="trivy cyclonedx sbom; keyless advisory database."><input id="modTrivy" type="checkbox" onchange="updatePipelinePreview()"/><span><strong>Trivy</strong><small>CycloneDX SBOM; keyless advisory database.</small></span></label>
79
  <label class="module-card" data-module-search="osv-scanner cyclonedx sbom; public advisory service."><input id="modOsv" type="checkbox" onchange="updatePipelinePreview()"/><span><strong>OSV-Scanner</strong><small>CycloneDX SBOM; public advisory service.</small></span></label></div></section><p id="moduleSearchEmpty" class="empty" hidden>No matching modules.</p></section>
@@ -88,7 +88,7 @@ https://example.org/,Main app</pre><small>TSV uses tabs. Supported target header
88
  <div class="settings-group" id="settings-inputs" hidden><section class="card"><span class="eyebrow">Query reference comparisons</span><h2>IDOR-Forge · limited GET profile</h2><p class="muted">Use explicit reference IDs you are authorized to test. This keyless, anonymous profile sends at most four GET requests to one path and never follows redirects. Configure one selected asset at a time. Without these inputs the module skips.</p>
89
  <div class="toolbar"><div class="field"><label for="moduleIdorPath">Same-origin endpoint path</label><input id="moduleIdorPath" maxlength="250" placeholder="/api/document"/></div><div class="field"><label for="moduleIdorParameter">Query parameter</label><input id="moduleIdorParameter" maxlength="50" value="id"/></div></div>
90
  <div class="toolbar"><div class="field"><label for="moduleIdorBaseline">Baseline reference ID</label><input id="moduleIdorBaseline" maxlength="100" autocomplete="off" placeholder="1001"/></div><div class="field"><label for="moduleIdorValues">Reference IDs to compare (up to 3, comma-separated)</label><input id="moduleIdorValues" maxlength="302" autocomplete="off" placeholder="1002, 1003"/></div></div>
91
- <p class="notice">Similarity is a review signal. This profile cannot prove object ownership or missing authorization; verify candidates using two authorized test accounts. Credentials, mutations, broad payload fuzzing and the upstream GUI are outside this profile. Reference values and response bodies are omitted from results and logs.</p></section><section class="card"><h2>Optional module inputs</h2><p class="muted">Optional inputs stay in this job's memory. Source/SBOM content is not included in saved results. Attach artifacts to one selected asset at a time. Full pipeline skips tools whose inputs are absent.</p><div class="field"><label for="moduleSource">Source text for TruffleHog (max 200,000 characters)</label><textarea id="moduleSource" maxlength="200000" rows="4" placeholder="Paste source or configuration text to inspect. Detected values and context are redacted."></textarea></div><p><label><input id="moduleTrufflehogVerify" type="checkbox"/> Verify found credentials I own or am authorized to test</label></p><p class="muted">Optional: sends detected credentials from the HF worker to their provider to check authentication. Supports AWS access-key pairs, GitHub, Slack, Hugging Face and OpenAI; up to 10 candidates within 30 seconds. Other types, known AWS canaries and candidates beyond this budget stay detection-only. Provider errors are inconclusive, never proof of an invalid secret. Values and account details are redacted. Full pipeline does not enable this option.</p><div class="field"><label for="moduleSbom">CycloneDX SBOM JSON for dep-scan, Trivy and OSV-Scanner (max 300 components)</label><textarea id="moduleSbom" maxlength="1000000" rows="4" placeholder='{"bomFormat":"CycloneDX","components":[{"name":"package","version":"1.0.0","purl":"pkg:npm/package@1.0.0"}]}'></textarea></div><p class="muted">These tools inspect supplied package metadata. OSV sends package names/versions to its public advisory service. Trivy and dep-scan fetch public databases on their first applicable run; database availability is shown in logs. dep-scan uses a smaller application-only database covering 2024 onward.</p><p><label><input id="moduleApiTarget" type="checkbox"/> This asset is an API target; allow Kiterunner's eight-route GET sample.</label></p><p class="muted">Amass uses standalone 4.2.0 passive discovery. Returned hostnames are inventory only and are not added to scan scope. Nettacker runs one HTTP-status check.</p><div class="toolbar"><div class="field"><label for="moduleLlmPath">Optional same-origin REST LLM path for garak</label><input id="moduleLlmPath" placeholder="/api/generate" maxlength="250"/></div><div class="field"><label>Top-level request prompt field</label><input id="modulePromptField" value="prompt" maxlength="50"/></div><div class="field"><label>Top-level response text field</label><input id="moduleResponseField" value="response" maxlength="50"/></div></div><p class="muted">garak runs two benign antivirus-signature output-filter checks. Use this only for an LLM expected to filter those signatures. No local model, API key, browser or GPU is required.</p></section></div>
92
  <div class="settings-group" id="settings-nuclei" hidden><section class="card" id="nucleiSettings"><h2>Nuclei · Template selection</h2>
93
  <p class="muted">Combine any bundled profiles, compatible community templates, and custom YAML. This selection applies to new scans only. Community and custom templates must pass the server's GET/HEAD-only validation; dynamic DSL, POST requests, remote interactions, and unsafe paths are blocked.</p>
94
  <div class="grid">
 
73
  <label class="module-card" data-module-search="dalfox unverified xss review."><input id="modDalfox" type="checkbox" onchange="updatePipelinePreview()"/><span><strong>Dalfox</strong><small>Unverified XSS review.</small></span></label>
74
  <label class="module-card" data-module-search="sqlmap low-risk boolean tests; no data extraction."><input id="modSqlmap" type="checkbox" onchange="updatePipelinePreview()"/><span><strong>SQLmap</strong><small>Low-risk boolean tests; no data extraction.</small></span></label>
75
  <label class="module-card" data-module-search="garak two benign output-filter checks; rest llm path required."><input id="modGarak" type="checkbox" onchange="updatePipelinePreview()"/><span><strong>garak</strong><small>Two benign output-filter checks; REST LLM path required.</small></span></label></div></section>
76
+ <section class="module-group"><h3>Source & dependencies</h3><div class="module-grid"><label class="module-card" data-module-search="trufflehog supplied source text; optional native credential verification."><input id="modTrufflehog" type="checkbox" onchange="updatePipelinePreview()"/><span><strong>TruffleHog</strong><small>Supplied source text; optional native credential verification.</small></span></label>
77
  <label class="module-card" data-module-search="dep-scan cyclonedx sbom; application-only database, 2024 onward."><input id="modDepscan" type="checkbox" onchange="updatePipelinePreview()"/><span><strong>dep-scan</strong><small>CycloneDX SBOM; application-only database, 2024 onward.</small></span></label>
78
  <label class="module-card" data-module-search="trivy cyclonedx sbom; keyless advisory database."><input id="modTrivy" type="checkbox" onchange="updatePipelinePreview()"/><span><strong>Trivy</strong><small>CycloneDX SBOM; keyless advisory database.</small></span></label>
79
  <label class="module-card" data-module-search="osv-scanner cyclonedx sbom; public advisory service."><input id="modOsv" type="checkbox" onchange="updatePipelinePreview()"/><span><strong>OSV-Scanner</strong><small>CycloneDX SBOM; public advisory service.</small></span></label></div></section><p id="moduleSearchEmpty" class="empty" hidden>No matching modules.</p></section>
 
88
  <div class="settings-group" id="settings-inputs" hidden><section class="card"><span class="eyebrow">Query reference comparisons</span><h2>IDOR-Forge · limited GET profile</h2><p class="muted">Use explicit reference IDs you are authorized to test. This keyless, anonymous profile sends at most four GET requests to one path and never follows redirects. Configure one selected asset at a time. Without these inputs the module skips.</p>
89
  <div class="toolbar"><div class="field"><label for="moduleIdorPath">Same-origin endpoint path</label><input id="moduleIdorPath" maxlength="250" placeholder="/api/document"/></div><div class="field"><label for="moduleIdorParameter">Query parameter</label><input id="moduleIdorParameter" maxlength="50" value="id"/></div></div>
90
  <div class="toolbar"><div class="field"><label for="moduleIdorBaseline">Baseline reference ID</label><input id="moduleIdorBaseline" maxlength="100" autocomplete="off" placeholder="1001"/></div><div class="field"><label for="moduleIdorValues">Reference IDs to compare (up to 3, comma-separated)</label><input id="moduleIdorValues" maxlength="302" autocomplete="off" placeholder="1002, 1003"/></div></div>
91
+ <p class="notice">Similarity is a review signal. This profile cannot prove object ownership or missing authorization; verify candidates using two authorized test accounts. Credentials, mutations, broad payload fuzzing and the upstream GUI are outside this profile. Reference values and response bodies are omitted from results and logs.</p></section><section class="card"><h2>Optional module inputs</h2><p class="muted">Optional inputs stay in this job's memory. Source/SBOM content is not included in saved results. Attach artifacts to one selected asset at a time. Full pipeline skips tools whose inputs are absent.</p><div class="field"><label for="moduleSource">Source text for TruffleHog (max 200,000 characters)</label><textarea id="moduleSource" maxlength="200000" rows="4" placeholder="Paste source or configuration text to inspect. Detected values and context are redacted."></textarea></div><p><label><input id="moduleTrufflehogVerify" type="checkbox"/> Verify found credentials I own or am authorized to test</label></p><p class="muted">Optional: runs TruffleHog’s built-in verification for all detector types against the original supplied source. Native detectors use the credential and its source context to check the relevant service, which can include a database or another host. No five-provider restriction or 10-candidate cap. One worker, with a 120-second overall process limit on HF. Some detectors may be unable to verify, skip verification or encounter network errors; an unverified result never proves a secret is invalid. Values, account details and provider errors are redacted. Full pipeline does not enable this option.</p><div class="field"><label for="moduleSbom">CycloneDX SBOM JSON for dep-scan, Trivy and OSV-Scanner (max 300 components)</label><textarea id="moduleSbom" maxlength="1000000" rows="4" placeholder='{"bomFormat":"CycloneDX","components":[{"name":"package","version":"1.0.0","purl":"pkg:npm/package@1.0.0"}]}'></textarea></div><p class="muted">These tools inspect supplied package metadata. OSV sends package names/versions to its public advisory service. Trivy and dep-scan fetch public databases on their first applicable run; database availability is shown in logs. dep-scan uses a smaller application-only database covering 2024 onward.</p><p><label><input id="moduleApiTarget" type="checkbox"/> This asset is an API target; allow Kiterunner's eight-route GET sample.</label></p><p class="muted">Amass uses standalone 4.2.0 passive discovery. Returned hostnames are inventory only and are not added to scan scope. Nettacker runs one HTTP-status check.</p><div class="toolbar"><div class="field"><label for="moduleLlmPath">Optional same-origin REST LLM path for garak</label><input id="moduleLlmPath" placeholder="/api/generate" maxlength="250"/></div><div class="field"><label>Top-level request prompt field</label><input id="modulePromptField" value="prompt" maxlength="50"/></div><div class="field"><label>Top-level response text field</label><input id="moduleResponseField" value="response" maxlength="50"/></div></div><p class="muted">garak runs two benign antivirus-signature output-filter checks. Use this only for an LLM expected to filter those signatures. No local model, API key, browser or GPU is required.</p></section></div>
92
  <div class="settings-group" id="settings-nuclei" hidden><section class="card" id="nucleiSettings"><h2>Nuclei · Template selection</h2>
93
  <p class="muted">Combine any bundled profiles, compatible community templates, and custom YAML. This selection applies to new scans only. Community and custom templates must pass the server's GET/HEAD-only validation; dynamic DSL, POST requests, remote interactions, and unsafe paths are blocked.</p>
94
  <div class="grid">
tests/test_final_modules.py CHANGED
@@ -201,143 +201,131 @@ def test_draft_endpoint_validates_but_never_launches_scan():
201
  assert response.json()["executed"] is False
202
 
203
 
204
- # Verification responses are mocked. No test authenticates a real credential.
205
- def run_secret_fixture(rows, reply=None, enabled=True):
206
- calls = []
207
- paths = []
 
 
 
 
 
 
 
208
  async def execute(args, timeout):
209
  calls.append((args, timeout))
210
  assert "--no-update" in args and "--force-skip-archives" in args
211
- path = Path(args[args.index("filesystem") + 1])
212
- if "--no-verification" in args:
213
- return rows, "completed"
214
- assert "--include-detectors" in args and "--allow-verification-overlap" in args
215
- assert "--no-verification-cache" in args
216
  assert args[args.index("--results") + 1] == "verified,unverified,unknown"
217
- assert timeout <= 12 and (path.stat().st_mode & 0o777) == 0o600
 
 
 
 
 
 
 
 
 
 
218
  assert "fixture-private" not in " ".join(args)
219
  paths.append(path)
220
- return reply(args, path) if reply else ([], "completed")
221
  with patch.object(modules, "execute", execute):
222
  result = asyncio.run(modules.run("trufflehog", "https://example.org/", "example.org", "Test", 1.5,
223
- ModuleInputs(source_text="fixture-private-source", trufflehog_verify=enabled)))
224
  assert all(not path.exists() for path in paths)
 
225
  assert "fixture-private" not in json.dumps(result)
226
- return result, calls
227
-
228
-
229
- def native_secret(name="Github", raw="fixture-private-key", **fields):
230
- return json.dumps({"DetectorName": name, "Raw": raw, "Redacted": raw,
231
- "ExtraData": {"account": "fixture-private-identity"}, **fields})
232
 
233
 
234
  @pytest.mark.parametrize("fields,status,expected", [
235
  ({"Verified": True}, "completed", "verified"),
236
  ({"Verified": False}, "completed", "unverified"),
237
  ({"Verified": False, "VerificationError": "fixture-private-provider-error"}, "completed", "unknown"),
238
- ({"Verified": False}, "timeout", "unknown"),
239
  ({"Verified": True}, "timeout", "verified"),
 
240
  ({}, "completed", "unknown"),
241
  ])
242
- def test_trufflehog_verification_labels_and_redaction(fields, status, expected):
243
- def reply(args, path):
244
- assert path.read_text() == "github_token = fixture-private-key"
245
- assert args[args.index("--include-detectors") + 1] == "Github"
246
- return [native_secret(**fields)], status
247
- result, calls = run_secret_fixture([native_secret()], reply)
248
  finding = result["alerts"][0]
249
- assert len(calls) == 2 and result["verification"]["attempted"] == 1
250
  assert finding["verification_status"] == expected
251
  assert finding["confidence"] == ("High" if expected == "verified" else "Low")
252
  assert finding["risk"] == ("High" if expected == "verified" else "Medium")
253
- assert result["status"] == ("partial" if expected == "unknown" else "findings")
 
 
 
 
254
 
255
 
256
- def test_detection_default_never_contacts_a_provider_even_if_native_claims_verified():
257
- result, calls = run_secret_fixture([native_secret(Verified=True)], enabled=False)
258
- assert len(calls) == 1
259
  assert result["alerts"][0]["verification_status"] == "not_attempted"
260
  assert result["verification"]["enabled"] is False
261
 
262
 
263
- def test_unsupported_detectors_and_known_aws_canaries_stay_detection_only():
264
- aws = native_secret("AWS", raw="AKIAABCDEFGHIJKLMNOP",
265
- RawV2="AKIAABCDEFGHIJKLMNOP:" + "a" * 40,
266
- ExtraData={"is_canary": "true"})
267
- result, calls = run_secret_fixture([native_secret("PrivateKey"), native_secret("Postgres"), aws])
268
- assert len(calls) == 1 and result["verification"]["attempted"] == 0
269
- assert result["verification"]["not_attempted"] == 3
270
 
271
 
272
- def test_missing_or_mismatched_verifier_output_is_inconclusive():
273
- result, _ = run_secret_fixture([native_secret()], lambda *args: (
274
- [native_secret(raw="fixture-private-other-key", Verified=True)], "completed"))
275
- assert result["verification"]["unknown"] == 1
276
- assert result["verification"]["verified"] == 0
 
277
 
278
 
279
- def test_verifier_exception_keeps_detection_without_leaking_exception():
280
- def reply(*args):
281
- raise RuntimeError("fixture-private-native-error")
282
- result, _ = run_secret_fixture([native_secret()], reply)
283
- assert result["status"] == "partial" and len(result["alerts"]) == 1
284
- assert result["verification"]["unknown"] == 1
 
285
 
286
 
287
- def test_verification_is_deduplicated_and_capped_without_hiding_remaining_detections():
288
- rows = [native_secret(raw="fixture-private-" + str(i)) for i in range(12)]
289
- result, calls = run_secret_fixture(rows + rows[:1])
290
- assert len(calls) == 11
291
- assert result["verification"]["attempted"] == 10
292
- assert result["verification"]["unknown"] == 10
293
- assert result["verification"]["not_attempted"] == 2
294
- assert result["verification"]["budget_limited"] == 2
295
- assert sum(item["candidate_count"] for item in result["alerts"]) == 12
296
 
297
 
298
- def test_verification_budget_does_not_start_more_processes_after_expiry():
299
- # Patch only the module-local clock object, leaving asyncio's clock intact.
300
- from types import SimpleNamespace
301
- times = iter([0, 0, 31])
302
- async def execute(args, timeout):
303
- return [native_secret()], "completed"
304
- with patch.object(modules, "time", SimpleNamespace(monotonic=lambda: next(times))), \
305
- patch.object(modules, "execute", execute):
306
- import tempfile
307
- with tempfile.TemporaryDirectory() as tmp:
308
- states, count = asyncio.run(modules.verify_trufflehog(
309
- [native_secret(), native_secret(raw="fixture-private-second")], Path(tmp)))
310
- assert count == 1
311
- assert list(states.values()) == ["unknown", "not_attempted"]
312
-
313
-
314
- @pytest.mark.parametrize("name", ["AWS", "Github", "Slack", "HuggingFace", "OpenAI"])
315
- def test_provider_detector_is_explicit_and_never_uses_supplied_custom_endpoints(name):
316
- key = "AKIAABCDEFGHIJKLMNOP"
317
- pair = key + ":" + "a" * 40
318
- row = native_secret(name, raw=key if name == "AWS" else "fixture-private-key",
319
- **({"RawV2": pair} if name == "AWS" else {}))
320
- def reply(args, path):
321
- assert args[args.index("--include-detectors") + 1] == name
322
- assert "--verifier" not in args and "--config" not in args
323
- if name == "AWS":
324
- assert path.read_text() == pair.replace(":", "\n")
325
- return [], "completed"
326
- result, calls = run_secret_fixture([row], reply)
327
- assert len(calls) == 2 and result["verification"]["unknown"] == 1
328
 
329
 
330
  def test_verification_flag_is_visible_but_source_stays_private_in_job_options():
331
  summary = ModuleInputs(source_text="fixture-private-source", trufflehog_verify=True).public_summary()
332
  assert summary["trufflehog_verify"] is True
333
  assert "fixture-private" not in json.dumps(summary)
334
-
335
-
336
- def test_multiple_candidates_of_one_detector_keep_separate_verification_results():
337
- rows = [native_secret(raw="fixture-private-good"), native_secret(raw="fixture-private-bad")]
338
- def reply(args, path):
339
- raw = path.read_text().split(" = ", 1)[1]
340
- return [native_secret(raw=raw, Verified=raw.endswith("good"))], "completed"
341
- result, _ = run_secret_fixture(rows, reply)
342
- assert result["verification"]["verified"] == result["verification"]["unverified"] == 1
343
- assert {a["verification_status"] for a in result["alerts"]} == {"verified", "unverified"}
 
201
  assert response.json()["executed"] is False
202
 
203
 
204
+ # Native process output is mocked. No test authenticates a real credential.
205
+ def native_secret(name="Github", raw="fixture-private-key", **fields):
206
+ return json.dumps({"DetectorName": name, "Raw": raw, "Redacted": raw,
207
+ "ExtraData": {"account": "fixture-private-identity"},
208
+ "SourceMetadata": {"source": "fixture-private-source"},
209
+ "SecretParts": {"key": raw}, **fields})
210
+
211
+
212
+ def run_secret_fixture(rows, enabled=True, native_status="completed"):
213
+ calls, paths = [], []
214
+ source = "database=https://fixture-private-host/\npassword=fixture-private-key\noriginal context"
215
  async def execute(args, timeout):
216
  calls.append((args, timeout))
217
  assert "--no-update" in args and "--force-skip-archives" in args
218
+ assert "--force-skip-binaries" in args
 
 
 
 
219
  assert args[args.index("--results") + 1] == "verified,unverified,unknown"
220
+ assert args[args.index("--concurrency") + 1] == "1"
221
+ # No allowlist, source reconstruction or replacement of built-in policy.
222
+ assert not {"--include-detectors", "--exclude-detectors", "--verifier", "--config",
223
+ "--allow-verification-overlap", "--no-verification-cache"} & set(args)
224
+ assert ("--no-verification" in args) is (not enabled)
225
+ assert timeout == (120 if enabled else 40)
226
+ root = Path(args[args.index("filesystem") + 1])
227
+ path = root / "source.txt"
228
+ assert path.read_text() == source
229
+ assert (path.stat().st_mode & 0o777) == 0o600
230
+ assert sorted(p.name for p in root.iterdir()) == ["source.txt"]
231
  assert "fixture-private" not in " ".join(args)
232
  paths.append(path)
233
+ return rows, native_status
234
  with patch.object(modules, "execute", execute):
235
  result = asyncio.run(modules.run("trufflehog", "https://example.org/", "example.org", "Test", 1.5,
236
+ ModuleInputs(source_text=source, trufflehog_verify=enabled)))
237
  assert all(not path.exists() for path in paths)
238
+ assert len(calls) == 1
239
  assert "fixture-private" not in json.dumps(result)
240
+ assert "attempted" not in result["verification"]
241
+ assert "limit" not in result["verification"]
242
+ return result
 
 
 
243
 
244
 
245
  @pytest.mark.parametrize("fields,status,expected", [
246
  ({"Verified": True}, "completed", "verified"),
247
  ({"Verified": False}, "completed", "unverified"),
248
  ({"Verified": False, "VerificationError": "fixture-private-provider-error"}, "completed", "unknown"),
249
+ ({"Verified": False}, "timeout", "unverified"),
250
  ({"Verified": True}, "timeout", "verified"),
251
+ ({"Verified": False, "VerificationSkipped": True}, "completed", "not_attempted"),
252
  ({}, "completed", "unknown"),
253
  ])
254
+ def test_native_verification_labels_and_redaction(fields, status, expected):
255
+ result = run_secret_fixture([native_secret(**fields)], native_status=status)
 
 
 
 
256
  finding = result["alerts"][0]
 
257
  assert finding["verification_status"] == expected
258
  assert finding["confidence"] == ("High" if expected == "verified" else "Low")
259
  assert finding["risk"] == ("High" if expected == "verified" else "Medium")
260
+ assert result["verification"][expected] == 1
261
+ assert result["verification"]["mode"] == "native"
262
+ assert result["verification"]["detectors"] == "all"
263
+ assert result["status"] == (status if status != "completed" else
264
+ "partial" if expected == "unknown" else "findings")
265
 
266
 
267
+ def test_detection_default_disables_native_verification_even_if_output_claims_verified():
268
+ result = run_secret_fixture([native_secret(Verified=True)], enabled=False)
 
269
  assert result["alerts"][0]["verification_status"] == "not_attempted"
270
  assert result["verification"]["enabled"] is False
271
 
272
 
273
+ @pytest.mark.parametrize("name", ["AWS", "Github", "Slack", "HuggingFace", "OpenAI",
274
+ "Stripe", "Postgres", "Azure", "PrivateKey"])
275
+ def test_native_verification_accepts_all_detector_results_without_application_allowlist(name):
276
+ result = run_secret_fixture([native_secret(name, Verified=True)])
277
+ assert result["verification"]["verified"] == 1
278
+ assert result["alerts"][0]["name"] == "Verified " + name + " secret"
 
279
 
280
 
281
+ def test_more_than_ten_credentials_remain_in_single_native_scan():
282
+ rows = [native_secret(raw="fixture-private-" + str(i), Verified=True) for i in range(15)]
283
+ result = run_secret_fixture(rows)
284
+ assert result["verification"]["verified"] == 15
285
+ assert result["verification"]["candidates_reported"] == 15
286
+ assert result["alerts"][0]["candidate_count"] == 15
287
 
288
 
289
+ def test_partial_native_output_retains_findings_and_timeout_status():
290
+ result = run_secret_fixture([native_secret(Verified=True),
291
+ "[diagnostic] Deadline exceeded; fixture-private-error"],
292
+ native_status="timeout")
293
+ assert result["status"] == "timeout"
294
+ assert result["verification"]["verified"] == 1
295
+ assert result["alerts"][0]["verification_status"] == "verified"
296
 
297
 
298
+ def test_empty_timeout_cannot_claim_clean_scan_or_completed_verification():
299
+ result = run_secret_fixture(["[diagnostic] Deadline exceeded"], native_status="timeout")
300
+ assert result["status"] == "timeout" and result["alerts"] == []
301
+ assert result["verification"]["candidates_reported"] == 0
 
 
 
 
 
302
 
303
 
304
+ def test_unverified_does_not_claim_invalid_or_known_verification_attempt():
305
+ result = run_secret_fixture([native_secret("UnsupportedExample", Verified=False)])
306
+ assert result["alerts"][0]["verification_status"] == "unverified"
307
+ assert "may not support" in result["alerts"][0]["description"]
308
+ assert "does not prove" in result["alerts"][0]["description"]
309
+
310
+
311
+ def test_native_verification_deduplicates_candidates_and_keeps_strongest_evidence():
312
+ rows = [native_secret(raw="fixture-private-good", Verified=False),
313
+ native_secret(raw="fixture-private-good", Verified=True),
314
+ native_secret(raw="fixture-private-bad", Verified=False)]
315
+ result = run_secret_fixture(rows)
316
+ assert result["verification"]["verified"] == result["verification"]["unverified"] == 1
317
+ assert result["verification"]["candidates_reported"] == 2
318
+ assert {a["verification_status"] for a in result["alerts"]} == {"verified", "unverified"}
319
+
320
+
321
+ def test_native_verification_summary_counts_all_candidates_beyond_alert_group_cap():
322
+ rows = [native_secret("Detector" + str(i), Verified=True) for i in range(101)]
323
+ result = run_secret_fixture(rows)
324
+ assert result["verification"]["verified"] == 101
325
+ assert len(result["alerts"]) == 100
 
 
 
 
 
 
 
 
326
 
327
 
328
  def test_verification_flag_is_visible_but_source_stays_private_in_job_options():
329
  summary = ModuleInputs(source_text="fixture-private-source", trufflehog_verify=True).public_summary()
330
  assert summary["trufflehog_verify"] is True
331
  assert "fixture-private" not in json.dumps(summary)
 
 
 
 
 
 
 
 
 
 
tests/trufflehog_offline_smoke.py CHANGED
@@ -10,9 +10,7 @@ with tempfile.TemporaryDirectory() as tmp:
10
  "-out", str(key)], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
11
  result = subprocess.run(["/usr/local/bin/trufflehog", "filesystem", tmp, "--json",
12
  "--no-verification", "--no-update", "--concurrency", "1",
13
- "--include-detectors", "PrivateKey,AWS,Github,Slack,HuggingFace,OpenAI",
14
- "--results", "verified,unverified,unknown", "--allow-verification-overlap",
15
- "--no-verification-cache"], capture_output=True, text=True, timeout=30)
16
  assert result.returncode == 0
17
  findings = [json.loads(line) for line in result.stdout.splitlines() if line.startswith("{")]
18
  assert any("private" in str(item.get("DetectorName", "")).lower() for item in findings)
 
10
  "-out", str(key)], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
11
  result = subprocess.run(["/usr/local/bin/trufflehog", "filesystem", tmp, "--json",
12
  "--no-verification", "--no-update", "--concurrency", "1",
13
+ "--results", "verified,unverified,unknown"], capture_output=True, text=True, timeout=30)
 
 
14
  assert result.returncode == 0
15
  findings = [json.loads(line) for line in result.stdout.splitlines() if line.startswith("{")]
16
  assert any("private" in str(item.get("DetectorName", "")).lower() for item in findings)