secretsieve 1.0.0__py3-none-any.whl

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.
Files changed (59) hide show
  1. secretsieve/__init__.py +9 -0
  2. secretsieve/__main__.py +6 -0
  3. secretsieve/cli/__init__.py +1 -0
  4. secretsieve/cli/app.py +274 -0
  5. secretsieve/cli/output.py +22 -0
  6. secretsieve/cli/parser.py +83 -0
  7. secretsieve/config/__init__.py +1 -0
  8. secretsieve/config/loader.py +285 -0
  9. secretsieve/config/template.py +55 -0
  10. secretsieve/detectors/__init__.py +1 -0
  11. secretsieve/detectors/context.py +149 -0
  12. secretsieve/detectors/engine.py +663 -0
  13. secretsieve/detectors/entropy.py +187 -0
  14. secretsieve/detectors/fp_filters.py +134 -0
  15. secretsieve/detectors/heuristics.py +79 -0
  16. secretsieve/detectors/scoring.py +95 -0
  17. secretsieve/detectors/signatures.py +75 -0
  18. secretsieve/git/__init__.py +56 -0
  19. secretsieve/models/__init__.py +1 -0
  20. secretsieve/models/config.py +46 -0
  21. secretsieve/models/finding.py +133 -0
  22. secretsieve/models/rule.py +119 -0
  23. secretsieve/models/stats.py +40 -0
  24. secretsieve/reporting/__init__.py +1 -0
  25. secretsieve/reporting/human.py +141 -0
  26. secretsieve/reporting/json_report.py +52 -0
  27. secretsieve/rules/__init__.py +65 -0
  28. secretsieve/rules/aws.py +36 -0
  29. secretsieve/rules/azure.py +20 -0
  30. secretsieve/rules/database.py +23 -0
  31. secretsieve/rules/discord.py +21 -0
  32. secretsieve/rules/dotenv.py +25 -0
  33. secretsieve/rules/generic.py +69 -0
  34. secretsieve/rules/github.py +20 -0
  35. secretsieve/rules/gitlab.py +20 -0
  36. secretsieve/rules/google.py +36 -0
  37. secretsieve/rules/jwt.py +24 -0
  38. secretsieve/rules/privatekey.py +22 -0
  39. secretsieve/rules/registry.py +78 -0
  40. secretsieve/rules/shell.py +23 -0
  41. secretsieve/rules/slack.py +20 -0
  42. secretsieve/rules/stripe.py +22 -0
  43. secretsieve/scanner/__init__.py +1 -0
  44. secretsieve/scanner/discovery.py +165 -0
  45. secretsieve/scanner/filetypes.py +120 -0
  46. secretsieve/scanner/filters.py +82 -0
  47. secretsieve/scanner/gating.py +32 -0
  48. secretsieve/scanner/orchestrator.py +140 -0
  49. secretsieve/scanner/reader.py +56 -0
  50. secretsieve/utils/__init__.py +1 -0
  51. secretsieve/utils/paths.py +59 -0
  52. secretsieve/utils/redact.py +61 -0
  53. secretsieve/utils/sanitize.py +35 -0
  54. secretsieve/utils/timing.py +23 -0
  55. secretsieve-1.0.0.dist-info/METADATA +109 -0
  56. secretsieve-1.0.0.dist-info/RECORD +59 -0
  57. secretsieve-1.0.0.dist-info/WHEEL +4 -0
  58. secretsieve-1.0.0.dist-info/entry_points.txt +2 -0
  59. secretsieve-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,9 @@
1
+ """SecretSieve - intelligent local-first secret detection CLI.
2
+
3
+ Copyright (c) 3rabDev - https://3rabdev.online
4
+ """
5
+
6
+ __version__ = "0.1.0"
7
+ __tool_name__ = "secretsieve"
8
+ __owner__ = "3rabDev"
9
+ __website__ = "https://3rabdev.online"
@@ -0,0 +1,6 @@
1
+ """Entry point for ``python -m secretsieve``. Parity with the ``secretsieve`` console script."""
2
+
3
+ from secretsieve.cli.app import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """CLI package (stdlib argparse only - no third-party CLI framework)."""
secretsieve/cli/app.py ADDED
@@ -0,0 +1,274 @@
1
+ """Scan controller: argv -> config -> engine -> reporter -> exit code.
2
+
3
+ Copyright (c) 3rabDev - https://3rabdev.online
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import sys
10
+ import traceback
11
+ from pathlib import Path
12
+
13
+ from secretsieve import __version__
14
+ from secretsieve.cli import output as outmod
15
+ from secretsieve.cli.parser import build_explicit_parser, build_scan_parser
16
+ from secretsieve.config import loader as config_loader
17
+ from secretsieve.config.template import TEMPLATE
18
+ from secretsieve.detectors import scoring
19
+ from secretsieve.scanner.orchestrator import run_scan
20
+
21
+ SUBCOMMANDS = ("scan", "rules", "explain", "config")
22
+
23
+
24
+ def main(argv: list[str] | None = None) -> int:
25
+ args_list = list(sys.argv[1:] if argv is None else argv)
26
+ try:
27
+ if args_list and args_list[0] in SUBCOMMANDS:
28
+ return _run_subcommand(args_list)
29
+ return _run_scan(args_list)
30
+ except (config_loader.ConfigError, FileNotFoundError) as exc:
31
+ print(f"secretsieve: error: {exc}", file=sys.stderr)
32
+ return 2
33
+ except BrokenPipeError:
34
+ return 2
35
+
36
+
37
+ # ------------------------------------------------------------------ scanning
38
+
39
+ def _run_scan(argv: list[str]) -> int:
40
+ parser = build_scan_parser()
41
+ args = parser.parse_args(argv)
42
+ if getattr(args, "version", False):
43
+ _print_version()
44
+ return 0
45
+ return _execute_scan(args, targets=args.paths or ["."])
46
+
47
+
48
+ def _execute_scan(args: object, targets: list[str]) -> int:
49
+ get = getattr
50
+ cfg, cfg_warnings = config_loader.discover_config(
51
+ get(args, "config", None), bool(get(args, "no_config", False)), Path.cwd()
52
+ )
53
+ cfg = config_loader.apply_cli(cfg, args)
54
+ if get(args, "min_confidence", None) is not None and not 0 <= int(get(args, "min_confidence")) <= 99:
55
+ print("secretsieve: error: --min-confidence must be 0-99", file=sys.stderr)
56
+ return 2
57
+ if get(args, "output", None) and not cfg.as_json:
58
+ print("secretsieve: error: --output requires --json", file=sys.stderr)
59
+ return 2
60
+
61
+ scan_targets = list(targets)
62
+ git_warnings: list[str] = []
63
+ if get(args, "staged", False) or get(args, "unstaged", False):
64
+ from secretsieve import git as gitmod
65
+
66
+ cwd = Path.cwd()
67
+ selected: list[str] = []
68
+ try:
69
+ if get(args, "staged", False):
70
+ selected += gitmod.get_staged_files(cwd)
71
+ if get(args, "unstaged", False):
72
+ selected += gitmod.get_unstaged_files(cwd)
73
+ except gitmod.GitError as exc:
74
+ print(f"secretsieve: error: {exc}", file=sys.stderr)
75
+ return 2
76
+ selected = [p for p in dict.fromkeys(selected) if (cwd / p).exists()]
77
+ if not selected:
78
+ git_warnings.append("git file list is empty; nothing to scan")
79
+ scan_targets = selected or []
80
+ if not scan_targets:
81
+ # Still emit a valid (empty) report for CI stability.
82
+ return _emit_empty(args, cfg, cfg_warnings + git_warnings)
83
+
84
+ try:
85
+ outcome = run_scan(scan_targets or ["."], cfg)
86
+ except FileNotFoundError as exc:
87
+ print(f"secretsieve: error: {exc}", file=sys.stderr)
88
+ return 2
89
+
90
+ warnings = cfg_warnings + git_warnings + outcome.warnings
91
+
92
+ gate = [f for f in outcome.findings if scoring.meets_fail_on(f.severity, cfg.fail_on)]
93
+ if gate:
94
+ exit_code = 1
95
+ elif outcome.stats.files_errored > 0:
96
+ exit_code = 2
97
+ else:
98
+ exit_code = 0
99
+
100
+ if cfg.as_json:
101
+ return _emit_json(args, cfg, outcome, warnings, exit_code)
102
+
103
+ display = [
104
+ f for f in outcome.findings
105
+ if scoring.meets_floor(f.severity, cfg.severity_floor) and f.confidence >= cfg.min_confidence
106
+ ]
107
+ text = outmod.render_human(
108
+ findings=display,
109
+ stats=outcome.stats,
110
+ version=__version__,
111
+ targets=scan_targets or ["."],
112
+ duration_ms=outcome.duration_ms,
113
+ exit_code=exit_code,
114
+ use_color=outmod.should_use_color(cfg.no_color, False),
115
+ quiet=cfg.quiet,
116
+ verbose=cfg.verbose,
117
+ warnings=warnings,
118
+ file_errors=list(outcome.stats.errors),
119
+ timing=dict(outcome.timing),
120
+ )
121
+ _write_stdout(text)
122
+ for w in warnings:
123
+ if not cfg.verbose:
124
+ print(f"secretsieve: warning: {w}", file=sys.stderr)
125
+ for e in outcome.stats.errors:
126
+ print(f"secretsieve: warning: {e}", file=sys.stderr)
127
+ return exit_code
128
+
129
+
130
+ def _emit_json(args: object, cfg: object, outcome: object, warnings: list[str], exit_code: int) -> int:
131
+ from secretsieve.reporting.json_report import build_report
132
+
133
+ get = getattr
134
+ report = build_report(
135
+ version=__version__,
136
+ scan_root=str(get(outcome, "scan_root", ".")),
137
+ duration_ms=int(get(outcome, "duration_ms", 0)),
138
+ stats=get(outcome, "stats"),
139
+ findings=list(get(outcome, "findings", [])),
140
+ config_snapshot=get(cfg, "snapshot")(),
141
+ exit_code=exit_code,
142
+ warnings=warnings,
143
+ )
144
+ payload = json.dumps(report, ensure_ascii=True, indent=2, sort_keys=False)
145
+ out_path = get(cfg, "output_path", None)
146
+ if out_path:
147
+ try:
148
+ Path(out_path).write_text(payload + "\n", encoding="utf-8")
149
+ except OSError as exc:
150
+ print(f"secretsieve: error: cannot write --output file: {exc}", file=sys.stderr)
151
+ return 2
152
+ else:
153
+ _write_stdout(payload + "\n")
154
+ for w in warnings:
155
+ print(f"secretsieve: warning: {w}", file=sys.stderr)
156
+ for e in get(get(outcome, "stats"), "errors", []):
157
+ print(f"secretsieve: warning: {e}", file=sys.stderr)
158
+ return exit_code
159
+
160
+
161
+ def _emit_empty(args: object, cfg: object, warnings: list[str]) -> int:
162
+ """Emit a valid empty report (used when git file lists are empty)."""
163
+ from secretsieve.models.stats import ScanStats
164
+ from types import SimpleNamespace
165
+
166
+ outcome = SimpleNamespace(findings=[], stats=ScanStats(), warnings=[], scan_root=Path.cwd(), duration_ms=0, timing={})
167
+ if bool(getattr(cfg, "as_json", False)):
168
+ return _emit_json(args, cfg, outcome, warnings, 0)
169
+ text = outmod.render_human(
170
+ findings=[], stats=outcome.stats, version=__version__, targets=["(git file list: empty)"],
171
+ duration_ms=0, exit_code=0, use_color=False, quiet=bool(getattr(cfg, "quiet", False)),
172
+ verbose=bool(getattr(cfg, "verbose", False)), warnings=warnings, file_errors=[],
173
+ )
174
+ _write_stdout(text)
175
+ return 0
176
+
177
+
178
+ def _write_stdout(text: str) -> None:
179
+ try:
180
+ sys.stdout.write(text)
181
+ except BrokenPipeError:
182
+ pass
183
+
184
+
185
+ # -------------------------------------------------------------- subcommands
186
+
187
+ def _run_subcommand(argv: list[str]) -> int:
188
+ name = argv[0]
189
+ if name == "scan":
190
+ parser = build_scan_parser()
191
+ args = parser.parse_args(argv[1:])
192
+ if getattr(args, "version", False):
193
+ _print_version()
194
+ return 0
195
+ return _execute_scan(args, targets=args.paths or ["."])
196
+ parser = build_explicit_parser()
197
+ args = parser.parse_args(argv)
198
+ cmd = getattr(args, "command", None)
199
+ if cmd == "rules":
200
+ return _cmd_rules(args)
201
+ if cmd == "explain":
202
+ return _cmd_explain(str(getattr(args, "rule_id", "")))
203
+ if cmd == "config":
204
+ return _cmd_config(args)
205
+ parser.print_help()
206
+ return 2
207
+
208
+
209
+ def _cmd_rules(args: object) -> int:
210
+ from secretsieve.rules import RULE_REGISTRY, RULES_BY_ID
211
+ from secretsieve.utils.sanitize import sanitize_for_terminal
212
+
213
+ if getattr(args, "list", False):
214
+ for rule in sorted(RULE_REGISTRY, key=lambda r: r.id):
215
+ print(f"{rule.id:<22} {rule.severity:<8} {sanitize_for_terminal(rule.name)}")
216
+ print(f"\n{len(RULE_REGISTRY)} rules - https://3rabdev.online")
217
+ return 0
218
+ show = getattr(args, "show", None)
219
+ if show:
220
+ return _cmd_explain(str(show))
221
+ return 2
222
+
223
+
224
+ def _cmd_explain(rule_id: str) -> int:
225
+ from secretsieve.rules import RULES_BY_ID
226
+ from secretsieve.utils.sanitize import sanitize_for_terminal
227
+
228
+ rid = rule_id.strip().upper()
229
+ # Accept the documented alias.
230
+ if rid == "SS-GENERIC-JWT-001":
231
+ rid = "SS-JWT-001"
232
+ rule = RULES_BY_ID.get(rid)
233
+ if rule is None:
234
+ print(f"secretsieve: error: unknown rule '{rule_id.strip()}' (try: secretsieve rules --list)", file=sys.stderr)
235
+ return 2
236
+ print(f"{sanitize_for_terminal(rule.id)} - {sanitize_for_terminal(rule.name)}")
237
+ print(f"Provider: {sanitize_for_terminal(rule.provider)}")
238
+ print(f"Category: {sanitize_for_terminal(rule.category)}")
239
+ print(f"Severity: {rule.severity}")
240
+ print(f"Confidence: base {rule.base_confidence} (adjusted by context, entropy, structure)")
241
+ print(f"Needs context: {'yes' if rule.context_required else 'no (standalone signature)'}")
242
+ print(f"Keywords: {sanitize_for_terminal(', '.join(rule.keywords) or '-')}")
243
+ print(f"About: {sanitize_for_terminal(rule.description)}")
244
+ print(f"Fix: {sanitize_for_terminal(rule.remediation)}")
245
+ print(f"FP notes: {sanitize_for_terminal(rule.fp_notes)}")
246
+ return 0
247
+
248
+
249
+ def _cmd_config(args: object) -> int:
250
+ if getattr(args, "init", False):
251
+ _write_stdout(TEMPLATE)
252
+ return 0
253
+ # --validate
254
+ try:
255
+ cfg, warnings = config_loader.discover_config(
256
+ getattr(args, "config", None), bool(getattr(args, "no_config", False)), Path.cwd()
257
+ )
258
+ except config_loader.ConfigError as exc:
259
+ print(f"secretsieve: error: {exc}", file=sys.stderr)
260
+ return 2
261
+ unknown = config_loader.validate_rule_ids(list(cfg.disabled_rules))
262
+ if unknown:
263
+ print(f"secretsieve: error: unknown rule ID(s): {', '.join(unknown)}", file=sys.stderr)
264
+ return 2
265
+ where = cfg.config_path or "(defaults; no config file found)"
266
+ print(f"config OK - {where}")
267
+ for w in warnings:
268
+ print(f"warning: {w}")
269
+ return 0
270
+
271
+
272
+ def _print_version() -> None:
273
+ print(f"secretsieve {__version__}")
274
+ print("Copyright (c) 3rabDev - https://3rabdev.online")
@@ -0,0 +1,22 @@
1
+ """Output helpers: TTY color detection + renderer re-exports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+
8
+ from secretsieve.reporting.human import render_human # noqa: F401 - public re-export
9
+ from secretsieve.reporting.json_report import build_report # noqa: F401 - public re-export
10
+
11
+
12
+ def should_use_color(no_color_flag: bool, json_mode: bool) -> bool:
13
+ if no_color_flag or json_mode:
14
+ return False
15
+ if os.environ.get("NO_COLOR"):
16
+ return False
17
+ if os.environ.get("TERM") == "dumb":
18
+ return False
19
+ try:
20
+ return sys.stdout.isatty()
21
+ except Exception:
22
+ return False
@@ -0,0 +1,83 @@
1
+ """Argument parsing (PLAN Sec. 24).
2
+
3
+ Dispatch model: ``scan`` (explicit or implicit), ``rules``, ``explain``,
4
+ ``config``. The implicit form (``secretsieve [PATH] [options]``) is parsed
5
+ with the same scan options as ``secretsieve scan ...``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+
12
+ SEVERITY_CHOICES = ("info", "low", "medium", "high", "critical")
13
+ FAIL_CHOICES = ("low", "medium", "high", "critical")
14
+
15
+ _EPILOG = (
16
+ "Privacy: SecretSieve is local-first - scanning performs no network I/O, "
17
+ "uploads nothing, and verifies nothing against third-party services. "
18
+ "Output is redacted by default. See https://3rabdev.online"
19
+ )
20
+
21
+
22
+ def add_scan_options(parser: argparse.ArgumentParser) -> argparse.ArgumentParser:
23
+ parser.add_argument("paths", nargs="*", default=[], help="files or directories to scan (default: .)")
24
+ parser.add_argument("--json", action="store_true", help="machine-readable JSON report on stdout")
25
+ parser.add_argument("-q", "--quiet", action="store_true", help="findings only (one line per finding)")
26
+ parser.add_argument("-v", "--verbose", action="store_true", help="detail without secrets: stats, suppress counts, skips")
27
+ parser.add_argument("--no-color", action="store_true", help="disable ANSI color")
28
+ parser.add_argument("--severity", choices=SEVERITY_CHOICES, default=None,
29
+ help="minimum DISPLAY severity (default: low; info shows INFO)")
30
+ parser.add_argument("--fail-on", choices=FAIL_CHOICES, default=None,
31
+ help="minimum severity that fails CI (default: low; INFO never fails)")
32
+ parser.add_argument("--min-confidence", type=int, default=None, help="hide findings below N (0-99)")
33
+ parser.add_argument("--exclude", action="append", default=[], metavar="GLOB",
34
+ help="extra exclude glob (repeatable)")
35
+ parser.add_argument("--include", action="append", default=[], metavar="GLOB",
36
+ help="restrict to matching paths (repeatable)")
37
+ parser.add_argument("--no-default-excludes", action="store_true", help="drop default directory exclusions")
38
+ parser.add_argument("--follow-symlinks", action="store_true", help="follow directory symlinks during descent")
39
+ parser.add_argument("--staged", action="store_true", help="scan files staged in the git index")
40
+ parser.add_argument("--unstaged", action="store_true", help="scan tracked-modified + untracked files")
41
+ parser.add_argument("--config", default=None, metavar="PATH", help="config file path")
42
+ parser.add_argument("--no-config", action="store_true", help="ignore config files (hermetic CI)")
43
+ parser.add_argument("--disable-rule", action="append", default=[], metavar="ID",
44
+ help="rule kill-switch, e.g. SS-DISCORD-001 (repeatable)")
45
+ parser.add_argument("--max-file-bytes", type=int, default=None, metavar="N", help="override size gate")
46
+ parser.add_argument("--output", default=None, metavar="PATH", help="write JSON report to file (with --json)")
47
+ return parser
48
+
49
+
50
+ def build_scan_parser() -> argparse.ArgumentParser:
51
+ parser = argparse.ArgumentParser(prog="secretsieve", description="SecretSieve - sieve the secrets out of your source.", epilog=_EPILOG)
52
+ parser.add_argument("--version", action="store_true", help="print version and exit")
53
+ return add_scan_options(parser)
54
+
55
+
56
+ def build_explicit_parser() -> argparse.ArgumentParser:
57
+ parser = argparse.ArgumentParser(prog="secretsieve", description="SecretSieve - sieve the secrets out of your source.", epilog=_EPILOG)
58
+ parser.add_argument("--version", action="store_true", help="print version and exit")
59
+ sub = parser.add_subparsers(dest="command")
60
+
61
+ scan_p = sub.add_parser("scan", help="scan files or directories (explicit form)")
62
+ add_scan_options(scan_p)
63
+
64
+ rules_p = sub.add_parser("rules", help="list / show detection rules")
65
+ group = rules_p.add_mutually_exclusive_group(required=True)
66
+ group.add_argument("--list", action="store_true", help="list all rules")
67
+ group.add_argument("--show", metavar="ID", help="show one rule card")
68
+
69
+ explain_p = sub.add_parser("explain", help="explain a rule ID")
70
+ explain_p.add_argument("rule_id", help="e.g. SS-GITHUB-001")
71
+
72
+ config_p = sub.add_parser("config", help="config helpers")
73
+ cgroup = config_p.add_mutually_exclusive_group(required=True)
74
+ cgroup.add_argument("--init", action="store_true", help="print a starter secretsieve.toml")
75
+ cgroup.add_argument("--validate", action="store_true", help="validate the resolved config file")
76
+ config_p.add_argument("--config", default=None, metavar="PATH", help="config file path")
77
+ config_p.add_argument("--no-config", action="store_true", help="ignore config files")
78
+ return parser
79
+
80
+
81
+ def parse_scan_args(argv: list[str]) -> argparse.Namespace:
82
+ """Parse implicit-scan argv (testable without I/O)."""
83
+ return build_scan_parser().parse_args(argv)
@@ -0,0 +1 @@
1
+ """Config package: TOML loading + template generation."""