A developer-facing security guardrail that intercepts commits containing hardcoded secrets, remediates them automatically, and generates safe retrieval code.
When a developer runs git commit, the detection engine scans staged files for secrets (API keys, private keys, credentials). If any are found:
- The commit is blocked
- The UI surfaces every finding with file, line, severity, and a masked preview
- The developer can review details, automatically store secrets in AWS Secrets Manager, and get generated retrieval code to drop back into their source
- Once remediated, the commit proceeds cleanly — no secret ever lands in git history
┌─────────────────────────────────────────────────────────────┐
│ Developer Machine │
│ │
│ git commit │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Detection Engine │ ← scans staged files with regexes │
│ │ (pre-commit hook) │ / Gemini AI pattern matching │
│ └──────────┬──────────┘ │
│ │ secrets found │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ SecretLens UI │ ← Flutter app (this repo) │
│ │ (Flutter / Dart) │ │
│ └──────────┬──────────┘ │
│ │ POST /store-secret │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Remediation API │ ← backend (future) │
│ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ AWS Secrets Manager│ ← encrypted secret storage │
│ └──────────┬──────────┘ │
│ │ generated ARN │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Generated Code │ ← shown in-app, ready to paste │
│ │ (Python / JS / sh) │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Feature-first layout with a clean separation of domain, data, and presentation layers.
lib/
├── main.dart # ProviderScope entry point
│
├── core/
│ ├── constants/
│ │ └── app_colors.dart # Design system colour tokens
│ └── theme/
│ └── app_theme.dart # Dark ThemeData configuration
│
└── features/
└── commit_block/
│
├── domain/
│ └── models/
│ └── finding.dart # Finding, Severity, FixStatus
│
└── presentation/
├── providers/
│ └── commit_block_provider.dart # State + Notifier
│
├── widgets/
│ ├── finding_tile.dart # Single secret card
│ ├── risk_warning_box.dart # Amber/red alert panel
│ └── footer_branding.dart # SecretLens + Gemini badge
│
└── screens/
├── commit_blocked_screen.dart # [1] Main blocked view
├── findings_detail_screen.dart # [2] Per-finding detail
├── fix_issues_progress_screen.dart # [3] Animated fix flow
├── secret_saved_screen.dart # [4] Success + ARNs
└── generated_snippet_screen.dart # [5] Copy-ready code
[1] CommitBlockedScreen
│
├── REVIEW FINDINGS ──────────► [2] FindingsDetailScreen
│ (tabbed, per-finding detail,
│ severity badge, masked value,
│ impact description)
│
├── FIX ALL ISSUES ───────────► [3] FixIssuesProgressScreen
│ (animated pulsing shield,
│ sequential secret processing,
│ live progress bar)
│ │
│ ▼
│ [4] SecretSavedScreen
│ (green checkmark,
│ stored ARNs listed,
│ what-happened summary)
│ │
│ ▼
│ [5] GeneratedSnippetScreen
│ (language-tagged blocks,
│ copy button per snippet,
│ reminder to remove originals)
│
└── COMMIT ANYWAY ────────────► Confirmation dialog → Override state
Riverpod — StateNotifierProvider<CommitBlockNotifier, CommitBlockState>
| Field | Type | Description |
|---|---|---|
findings |
List<Finding> |
All detected secrets for this commit |
fixInProgress |
bool |
True while fixAllIssues() is running |
fixCompleted |
bool |
True after all secrets are stored |
commitOverridden |
bool |
True if developer chose to commit anyway |
currentFixIndex |
int |
Index of the secret currently being processed |
fixStatusMessage |
String? |
Live status label shown during the fix flow |
Computed getters:
riskLevel— derivesCRITICAL / HIGH / MEDIUM / LOWfrom finding severitiesisBlocked—truewhile neither fixed nor overriddenunfixedCount/fixedCount— drive progress indicators
| Method | What it does |
|---|---|
fixAllIssues() |
Async loop — processes each secret, emits granular state updates, stores ARN + generated code per finding |
commitAnyway() |
Sets commitOverridden = true (logged for audit) |
reset() |
Restores to initial mock state (demo / test use) |
Finding
├── id String unique identifier
├── secretType String e.g. "AWS Access Key"
├── file String relative path e.g. "src/config.js"
├── line int line number in that file
├── severity Severity critical | high | medium | low
├── maskedValue String redacted preview shown in UI
├── language String? "javascript" | "python" | "shell"
├── fixStatus FixStatus unfixed | inProgress | stored | failed
├── storedSecretArn String? ARN assigned after AWS storage
└── generatedCode String? retrieval snippet, set after fix
Severity: critical → red (#FF3B3B)
high → orange (#FF6B35)
medium → amber (#FFB800)
low → blue (#4D9FFF)
FixStatus: unfixed | inProgress | stored | failed
The app ships with three hardcoded findings that simulate a real detection result:
| # | Secret Type | File | Line | Severity | Language |
|---|---|---|---|---|---|
| 1 | AWS Access Key | src/config.js |
23 | CRITICAL | JavaScript |
| 2 | RSA Private Key | backend/auth.py |
7 | CRITICAL | Python |
| 3 | Stripe API Secret | .env |
12 | HIGH | Shell |
Generated code templates are inline in commit_block_provider.dart and produce AWS SDK v3 (JS), boto3 (Python), or AWS CLI (shell) snippets pointing to the stored secret ARN.
Dark security aesthetic throughout. All tokens live in lib/core/constants/app_colors.dart.
| Token | Hex | Usage |
|---|---|---|
background |
#080810 |
Scaffold background |
surface |
#10101C |
AppBar, section backgrounds |
card |
#16162A |
Finding cards |
codeBlock |
#0D0D1A |
Inline code backgrounds |
danger |
#FF3B3B |
BLOCKED header, CRITICAL badge |
warning |
#FFB800 |
Risk warning box, HIGH severity |
success |
#00D97E |
Secured badge, progress complete |
info |
#4D9FFF |
Gemini AI badge, LOW severity |
textCode |
#7EFFA0 |
Monospace code text |
textSecondary |
#8A8AA8 |
Subtitles, metadata |
| Layer | Technology |
|---|---|
| UI Framework | Flutter (Dart) |
| State | flutter_riverpod ^2.6.1 |
| Platforms | iOS · Android · macOS · Web |
| Cloud Storage | AWS Secrets Manager (stubbed) |
| AI Detection | Gemini AI (planned integration) |
# Install dependencies
flutter pub get
# Run on your device / simulator
flutter run
# Static analysis
flutter analyzeRequirements: Flutter SDK ≥ 3.x · Dart SDK ≥ 3.x
| Area | Status |
|---|---|
| CommitBlockedScreen UI | ✅ Complete |
| FindingsDetailScreen | ✅ Complete |
| FixIssuesProgressScreen | ✅ Complete |
| SecretSavedScreen | ✅ Complete |
| GeneratedSnippetScreen | ✅ Complete |
| Riverpod state management | ✅ Complete |
| Mock detection data | ✅ 3 findings |
| Real detection engine hook | 🔲 Planned |
| Remediation API integration | 🔲 Planned |
| AWS Secrets Manager calls | 🔲 Planned |
| Gemini AI scanning | 🔲 Planned |
| Secret history dashboard | 🔲 Planned |
| Repo security score screen | 🔲 Planned |
POST /scan → trigger secret scan on staged files
POST /store-secret → store a found secret in AWS Secrets Manager
POST /generate-snippet → return language-specific retrieval code
GET /history → fetch previously remediated secrets
This section documents how data moves through the Flutter app from ingestion to UI rendering and user-triggered actions. No backend or scanning engine exists yet — the app is driven by mock data. This document describes both the current internal flow and exactly where real API calls will slot in once the backend is built.
Data Source Provider Layer UI Layer
───────────────── ────────────────────────── ──────────────────────────────
CommitBlockNotifier
[Mock findings] ──────────► initializes with CommitBlockedScreen
[Future: POST /scan] List<Finding> ├─ reads state.findings
│ ├─ reads state.riskLevel
│ state emitted ├─ reads state.findings.length
▼ │
CommitBlockState ◄── ref.watch ──┤
│ ├─ FindingsDetailScreen
│ │ reads findings[id]
│ │ reads finding.fixStatus
│ │ reads finding.storedSecretArn
│ │
fixAllIssues() ├─ FixIssuesProgressScreen
│ │ reads currentFixIndex
│ emits on each phase │ reads fixedCount
▼ │ reads fixStatusMessage
Finding.copyWith() │ reads finding.fixStatus
fixStatus → stored │
storedSecretArn = ARN ├─ SecretSavedScreen
generatedCode = snippet │ reads findings where
│ │ storedSecretArn != null
│ │
│ └─ GeneratedSnippetScreen
│ reads findings where
└──────────────────────────► generatedCode != null
There is no API call at startup. CommitBlockNotifier initialises its state directly from a hardcoded list defined at the top of commit_block_provider.dart:
final _mockFindings = [
const Finding(
id: 'finding-001',
secretType: 'AWS Access Key',
file: 'src/config.js',
line: 23,
severity: Severity.critical,
maskedValue: 'AKIA••••••••••••WXYZ',
language: 'javascript',
),
// ... two more findings
];
class CommitBlockNotifier extends StateNotifier<CommitBlockState> {
CommitBlockNotifier()
: super(CommitBlockState(findings: List.from(_mockFindings)));
}The notifier is instantiated once, lazily, the first time any widget calls ref.watch(commitBlockProvider). The ProviderScope at the root of the widget tree in main.dart owns the provider lifetime.
When the detection engine is integrated, CommitBlockNotifier will accept the scan payload and convert it into Finding objects before setting initial state. The expected JSON shape from POST /scan is:
{
"commitHash": "a3f8c21",
"repoPath": "/Users/dev/myproject",
"blocked": true,
"findings": [
{
"id": "finding-001",
"secretType": "AWS Access Key",
"file": "src/config.js",
"line": 23,
"severity": "critical",
"maskedValue": "AKIA••••••••••••WXYZ",
"language": "javascript"
},
{
"id": "finding-002",
"secretType": "RSA Private Key",
"file": "backend/auth.py",
"line": 7,
"severity": "critical",
"maskedValue": "-----BEGIN RSA PRIVATE KEY-----\nMIIEow••••••••••••",
"language": "python"
}
]
}Each item in findings maps directly to a Finding model. The severity string "critical" | "high" | "medium" | "low" must be parsed into the Severity enum. maskedValue is computed server-side — the raw secret is never sent to the client.
Finding is a plain immutable Dart class in lib/features/commit_block/domain/models/finding.dart. It is the only domain object in the app and is the unit of data that flows through every layer.
Finding (immutable)
├── id String — stable identifier used for tab selection and ARN path
├── secretType String — human-readable label e.g. "Stripe API Secret"
├── file String — repo-relative file path
├── line int — 1-based line number within that file
├── severity Severity — drives colour, badge text, riskLevel computation
├── maskedValue String — redacted preview; never the real secret
├── language String? — determines which code template is generated
├── fixStatus FixStatus — lifecycle state of this finding
├── storedSecretArn String? — null until POST /store-secret succeeds
└── generatedCode String? — null until _generateRetrievalCode() runs
FixStatus lifecycle:
unfixed ──► inProgress ──► stored
│
└──► failed (reserved; not yet triggered in UI)
Immutability via copyWith:
Finding is never mutated in place. Every state transition produces a new Finding instance via copyWith, which only allows updating fixStatus, storedSecretArn, and generatedCode — the three fields that change during the fix flow. The identity fields (id, secretType, file, line, severity, maskedValue, language) are permanently fixed at construction time.
// Example: transitioning a finding to in-progress
updatedFindings[i] = updatedFindings[i].copyWith(
fixStatus: FixStatus.inProgress,
);
// Example: transitioning to stored after API call
updatedFindings[i] = updatedFindings[i].copyWith(
fixStatus: FixStatus.stored,
storedSecretArn: 'arn:aws:secretsmanager:...',
generatedCode: '...',
);Severity extension:
SeverityExt (on the Severity enum) provides .label (string for badges) and .colorValue (ARGB int for Color()), keeping colour logic in the domain layer rather than scattered across widgets.
CommitBlockState is the single source of truth for the entire feature. It is immutable — every mutation returns a new instance via copyWith.
class CommitBlockState {
final List<Finding> findings; // core data — never mutated directly
final bool fixInProgress; // true during fixAllIssues() async loop
final bool fixCompleted; // true once all findings reach FixStatus.stored
final bool commitOverridden; // true if developer dismissed the block
final int currentFixIndex; // which finding is being processed right now
final String? fixStatusMessage; // live human-readable phase label
}Computed getters derive secondary values from the primary fields on every rebuild — no stale derived state:
| Getter | Logic | Consumed by |
|---|---|---|
riskLevel |
Checks severities in priority order: critical → high → medium → low | RiskWarningBox, CommitBlockedScreen badge |
isBlocked |
!commitOverridden && !fixCompleted |
CommitBlockedScreen branch logic |
unfixedCount |
findings.where(f => f.fixStatus == FixStatus.unfixed).length |
FindingsDetailScreen unresolved counter |
fixedCount |
findings.where(f => f.fixStatus == FixStatus.stored).length |
FixIssuesProgressScreen progress bar numerator |
The notifier exposes three public methods. All state updates flow exclusively through these — no widget writes to state directly.
This is the most data-intensive operation in the app. It runs as a sequential async loop and emits multiple discrete state snapshots per finding so the UI can animate each phase in real time.
Phase sequence per finding:
Phase 0 — mark in-progress
state.findings[i].fixStatus = FixStatus.inProgress
state.currentFixIndex = i
state.fixStatusMessage = 'Analyzing <secretType>...'
↓ await 800ms
Phase 1 — storage phase label
state.fixStatusMessage = 'Storing in AWS Secrets Manager...'
[Future: POST /store-secret called here]
↓ await 1000ms
Phase 2 — code generation label
state.fixStatusMessage = 'Generating retrieval code...'
[Future: POST /generate-snippet called here]
↓ await 600ms
Phase 3 — mark stored
state.findings[i].fixStatus = FixStatus.stored
state.findings[i].storedSecretArn = 'arn:aws:...'
state.findings[i].generatedCode = <generated snippet>
state.currentFixIndex = i + 1
After the loop completes across all findings:
state.fixInProgress = false
state.fixCompleted = true
state.fixStatusMessage = 'All secrets secured'
FixIssuesProgressScreen then auto-navigates to SecretSavedScreen via Navigator.pushReplacement.
Why multiple emits per finding?
Each state = state.copyWith(...) call triggers a rebuild of every widget that has called ref.watch(commitBlockProvider). Emitting separately for the label change (fixStatusMessage) and for the data change (findings[i].fixStatus) gives the progress screen smooth, granular animation without batching.
How _ProgressItem determines its display state:
final isCurrent = index == state.currentFixIndex && state.fixInProgress;
final isDone = finding.fixStatus == FixStatus.stored;
final isPending = !isDone && index > state.currentFixIndex;This drives three distinct visual states — spinner (current), checkmark (done), hollow circle (pending) — purely from provider state, with no local widget state involved.
ref.watch(commitBlockProvider)
│
├── state.commitOverridden → if true, replaces entire scaffold with override view
├── state.findings → ListView.builder itemCount + Finding passed to FindingTile
├── state.findings.length → subtitle "N secrets detected..."
├── state.riskLevel → RiskWarningBox(riskLevel:)
└── state.findings.length → RiskWarningBox(findingCount:)
User actions:
"REVIEW FINDINGS" → Navigator.push(FindingsDetailScreen()) // read-only, no state change
"FIX ALL ISSUES" → Navigator.push(FixIssuesProgressScreen()) // triggers fixAllIssues()
"COMMIT ANYWAY" → dialog → ref.read(...notifier).commitAnyway() // sets commitOverridden=true
tap FindingTile → Navigator.push(FindingsDetailScreen(selectedFindingId: finding.id))
Accepts an optional selectedFindingId constructor argument. If provided (tap from list), that finding's tab is pre-selected. If absent (tap from button), defaults to findings.first.
ref.watch(commitBlockProvider)
│
├── state.findings → tab strip + active finding lookup by id
├── state.unfixedCount → AppBar badge "N UNRESOLVED"
└── activeFinding → _FindingDetailCard receives the full Finding object
├── finding.secretType, .file, .line → detail rows
├── finding.severity → badge colour + label
├── finding.maskedValue → _CopyableCodeBlock
├── finding.fixStatus == stored → conditionally shows ARN block / impact panel
└── finding.storedSecretArn → _CopyableCodeBlock (green, post-fix only)
Tab selection is local setState on _activeFindingId — it is pure navigation state, not persisted to the provider.
This screen owns the fix lifecycle. On first frame it calls fixAllIssues() and then watches the provider for every subsequent state emission.
initState → WidgetsBinding.addPostFrameCallback
└── _startFix()
└── ref.read(commitBlockProvider.notifier).fixAllIssues() // drives all state
└── on completion → Navigator.pushReplacement(SecretSavedScreen)
ref.watch(commitBlockProvider)
│
├── state.fixStatusMessage → subtitle label beneath "SECURING SECRETS"
├── state.findings → ListView.builder, one _ProgressItem per finding
├── state.currentFixIndex → determines which item gets spinner
├── state.fixInProgress → combined with index to derive isCurrent
├── state.fixedCount → LinearProgressIndicator value numerator
└── finding.fixStatus → isDone / isCurrent / isPending per item
finding.storedSecretArn → shown inside _ProgressItem once stored
PopScope(canPop: false) prevents back navigation during processing.
Read-only success view. Derives its display data by filtering the findings list.
ref.watch(commitBlockProvider)
│
└── state.findings.where(f => f.storedSecretArn != null)
└── for each stored finding:
finding.secretType → card label
finding.storedSecretArn → monospace ARN display
User actions:
"VIEW GENERATED CODE" → Navigator.push(GeneratedSnippetScreen())
"DONE" / close → Navigator.popUntil(route.isFirst) // back to CommitBlockedScreen
Read-only code display. Derives its list by filtering for findings that have completed code generation.
ref.watch(commitBlockProvider)
│
└── state.findings.where(f => f.generatedCode != null)
└── for each finding:
finding.secretType → card header
finding.file → subtitle
finding.language → language badge colour + label
finding.generatedCode → SelectableText in code block
finding.storedSecretArn → footer ARN
Local state (setState, not provider):
_copiedIds: Set<String> → tracks which snippet copy buttons show "COPIED"
resets per id after 2 seconds via Future.delayed
| User Action | Navigator Effect | Provider Effect |
|---|---|---|
| Tap "REVIEW FINDINGS" | Push FindingsDetailScreen |
None |
Tap a FindingTile |
Push FindingsDetailScreen(id:) |
None |
| Switch tab in detail screen | None | None (local setState only) |
| Tap "FIX ALL ISSUES" | Push FixIssuesProgressScreen |
fixAllIssues() runs — emits ~4 states per finding |
| Fix loop completes | pushReplacement(SecretSavedScreen) |
fixCompleted=true, fixInProgress=false |
| Tap "VIEW GENERATED CODE" | Push GeneratedSnippetScreen |
None |
| Tap "COMMIT IS CLEAR" | popUntil(isFirst) |
None |
| Tap "COMMIT ANYWAY" | Opens AlertDialog |
None yet |
| Confirm "COMMIT ANYWAY" | pop() dialog |
commitAnyway() → commitOverridden=true |
| Tap "RESTART DEMO" (override) | None | reset() → restores all 3 mock findings to FixStatus.unfixed |
The four planned endpoints slot into the flow at specific locations. No repository or datasource layer exists yet — when built, each endpoint belongs in lib/features/commit_block/data/ and is called from CommitBlockNotifier.
Trigger: Called by the pre-commit hook or on app startup, before CommitBlockedScreen is shown.
Slot: Replaces the hardcoded _mockFindings list in CommitBlockNotifier.
Request body:
{
"repoPath": "/Users/dev/project",
"stagedFiles": ["src/config.js", "backend/auth.py", ".env"]
}
Response body:
{
"commitHash": "a3f8c21",
"blocked": true,
"findings": [
{
"id": "finding-001",
"secretType": "AWS Access Key",
"file": "src/config.js",
"line": 23,
"severity": "critical",
"maskedValue": "AKIA••••••••••••WXYZ",
"language": "javascript"
}
]
}
severity string → Severity enum via a fromString factory (to be added to finding.dart).
masked_value is server-computed; the raw secret never leaves the detection engine.
Trigger: Called once per finding inside the fixAllIssues() loop, during Phase 1.
Slot: Replaces the hardcoded ARN string 'arn:aws:secretsmanager:us-east-1:123456789012:secret:secretlens/${finding.id}'.
Request body:
{
"findingId": "finding-001",
"secretType": "AWS Access Key",
"file": "src/config.js",
"line": 23
}
Response body:
{
"findingId": "finding-001",
"arn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:secretlens/finding-001-a1b2c3"
}
The arn from the response is stored in Finding.storedSecretArn via copyWith and immediately rendered in FixIssuesProgressScreen and SecretSavedScreen.
Trigger: Called once per finding inside the fixAllIssues() loop, during Phase 2, after the ARN is known.
Slot: Replaces _generateRetrievalCode() in CommitBlockNotifier.
Request body:
{
"findingId": "finding-001",
"arn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:secretlens/finding-001-a1b2c3",
"language": "javascript"
}
Response body:
{
"findingId": "finding-001",
"language": "javascript",
"snippet": "const { SecretsManagerClient, GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager');\n\n..."
}
The snippet is stored in Finding.generatedCode via copyWith and rendered verbatim in GeneratedSnippetScreen inside a SelectableText.
Trigger: On load of a future "Secret History" screen (not yet built).
Slot: Will power a new screen (SecretHistoryScreen) and a new provider (secretHistoryProvider), separate from commitBlockProvider.
Response body:
{
"entries": [
{
"findingId": "finding-001",
"secretType": "AWS Access Key",
"file": "src/config.js",
"storedAt": "2026-03-08T14:22:00Z",
"arn": "arn:aws:secretsmanager:...",
"commitHash": "a3f8c21",
"repoPath": "/Users/dev/project"
}
]
}
When backend integration is added, the recommended locations following the existing feature-first architecture are:
lib/features/commit_block/
├── data/
│ ├── repositories/
│ │ └── commit_block_repository.dart # interface + impl
│ └── datasources/
│ └── secrets_api_datasource.dart # HTTP calls via dio / http
└── domain/
└── usecases/ # optional, if logic grows complex
├── scan_commit_usecase.dart
├── store_secret_usecase.dart
└── generate_snippet_usecase.dart
CommitBlockNotifier will receive the repository via constructor injection, keeping it testable without a real network:
class CommitBlockNotifier extends StateNotifier<CommitBlockState> {
final CommitBlockRepository _repository;
CommitBlockNotifier(this._repository)
: super(const CommitBlockState(findings: []));
}
final commitBlockProvider =
StateNotifierProvider<CommitBlockNotifier, CommitBlockState>(
(ref) => CommitBlockNotifier(ref.watch(commitBlockRepositoryProvider)),
);SecretLens — Powered by Gemini AI