A penetration test produces a report. The report lists findings. Engineering remediates the findings. The security team marks them closed. The compliance auditor receives a report showing all findings resolved. Everyone moves on.
Six months later, a different penetration testing firm runs the same test. Eight of the twelve previously "closed" findings are still exploitable.
This is not hypothetical. It is the statistical reality of penetration test remediation at scale. Studies across thousands of penetration test engagements consistently show that 40–60% of findings that engineering teams mark as remediated fail independent retest verification. The finding was patched in one environment but not another. The root cause was addressed in one code path but not the others. The fix worked for the specific payload in the original report but fails for minor variations. The remediation was applied to staging but never deployed to production.
Retest methodology — how penetration testing firms verify that remediations actually work — is the least discussed and most consequential phase of the engagement. A penetration test without rigorous retest is a vulnerability list, not a security assurance. And the distinction matters enormously for compliance auditors, who increasingly require evidence not just that findings were identified but that remediations were independently verified.
This guide covers the complete retest methodology: what a retest actually verifies, how to structure remediation evidence, what auditors look for, why findings fail retests even when developers are confident the fix is correct, and how to close findings correctly the first time.
Related: What Is a CVSS Score? A Technical Breakdown | How to Choose an AI Penetration Testing Provider
What a Retest Is — and What It Isn't
The Precise Definition
A penetration test retest is a structured verification engagement in which the penetration testing team re-executes the specific exploit techniques used to confirm each original finding, against the production environment where the remediation has been applied, to verify that the remediation successfully prevents exploitation.
Three elements of that definition matter:
Re-executes specific exploit techniques: The retest uses the exact attack vector documented in the original finding. If the original finding was an IDOR via GET /api/users/{id} with another user's ID, the retest sends that exact request pattern. It also tests variations — other user IDs, other endpoints following the same pattern, adjacent attack surface the original finding revealed.
Against the production environment: Not staging. Not a QA environment. The environment where the remediation needs to hold. This is the most common retest failure point — remediations applied to non-production environments that never make it to production.
Where the remediation has been applied: The retest is not a new penetration test. It's targeted verification of specific fixes. The scope is the original finding set plus variation testing around each finding.
What a Retest Is Not
The most dangerous misunderstanding: organizations that treat a successful retest as evidence that the application is secure. A retest verifies that the specific findings from the original engagement are remediated. It says nothing about vulnerabilities that the original engagement didn't find, new code introduced since the original test, or attack surface that was out of scope.
Why Remediations Fail Retest: The Complete Taxonomy
Understanding why remediations fail is the prerequisite for structuring remediations that pass. There are six distinct failure categories:
Failure Mode 1: Incomplete Fix — Root Cause Not Addressed
The most common failure. The developer fixes the specific instance of the vulnerability reported but not the underlying pattern that causes it.
# Original finding: IDOR on GET /api/invoices/{id} # The endpoint returned any invoice by ID without ownership check # WRONG remediation — fixes only the reported endpoint: @app.route('/api/invoices/<int:invoice_id>') @login_required def get_invoice(invoice_id): invoice = Invoice.query.get(invoice_id) # Added after finding: if invoice.user_id != current_user.id: abort(403) return jsonify(invoice.to_dict()) # Retest result: ORIGINAL FINDING PASSES — this specific endpoint is fixed # But variation testing finds: # GET /api/invoices/{id}/pdf ← Same IDOR, different endpoint # GET /api/invoices/{id}/line-items ← Same IDOR, sub-resource # GET /api/invoices/{id}/history ← Same IDOR, history endpoint # PUT /api/invoices/{id} ← Same IDOR on update # DELETE /api/invoices/{id} ← Same IDOR on delete # The fix addressed the symptom (one endpoint) not the cause # (missing ownership filter pattern across all Invoice operations) # CORRECT remediation — fixes the root cause: class InvoiceQuery: @staticmethod def get_for_current_user(invoice_id): """ALWAYS filter by current user — impossible to use without ownership check""" return Invoice.query.filter_by( id=invoice_id, user_id=current_user.id ).first_or_404() # All Invoice endpoints now use InvoiceQuery.get_for_current_user() # Root cause addressed — not just the reported symptom
# Original finding: IDOR on GET /api/invoices/{id} # The endpoint returned any invoice by ID without ownership check # WRONG remediation — fixes only the reported endpoint: @app.route('/api/invoices/<int:invoice_id>') @login_required def get_invoice(invoice_id): invoice = Invoice.query.get(invoice_id) # Added after finding: if invoice.user_id != current_user.id: abort(403) return jsonify(invoice.to_dict()) # Retest result: ORIGINAL FINDING PASSES — this specific endpoint is fixed # But variation testing finds: # GET /api/invoices/{id}/pdf ← Same IDOR, different endpoint # GET /api/invoices/{id}/line-items ← Same IDOR, sub-resource # GET /api/invoices/{id}/history ← Same IDOR, history endpoint # PUT /api/invoices/{id} ← Same IDOR on update # DELETE /api/invoices/{id} ← Same IDOR on delete # The fix addressed the symptom (one endpoint) not the cause # (missing ownership filter pattern across all Invoice operations) # CORRECT remediation — fixes the root cause: class InvoiceQuery: @staticmethod def get_for_current_user(invoice_id): """ALWAYS filter by current user — impossible to use without ownership check""" return Invoice.query.filter_by( id=invoice_id, user_id=current_user.id ).first_or_404() # All Invoice endpoints now use InvoiceQuery.get_for_current_user() # Root cause addressed — not just the reported symptom
# Original finding: IDOR on GET /api/invoices/{id} # The endpoint returned any invoice by ID without ownership check # WRONG remediation — fixes only the reported endpoint: @app.route('/api/invoices/<int:invoice_id>') @login_required def get_invoice(invoice_id): invoice = Invoice.query.get(invoice_id) # Added after finding: if invoice.user_id != current_user.id: abort(403) return jsonify(invoice.to_dict()) # Retest result: ORIGINAL FINDING PASSES — this specific endpoint is fixed # But variation testing finds: # GET /api/invoices/{id}/pdf ← Same IDOR, different endpoint # GET /api/invoices/{id}/line-items ← Same IDOR, sub-resource # GET /api/invoices/{id}/history ← Same IDOR, history endpoint # PUT /api/invoices/{id} ← Same IDOR on update # DELETE /api/invoices/{id} ← Same IDOR on delete # The fix addressed the symptom (one endpoint) not the cause # (missing ownership filter pattern across all Invoice operations) # CORRECT remediation — fixes the root cause: class InvoiceQuery: @staticmethod def get_for_current_user(invoice_id): """ALWAYS filter by current user — impossible to use without ownership check""" return Invoice.query.filter_by( id=invoice_id, user_id=current_user.id ).first_or_404() # All Invoice endpoints now use InvoiceQuery.get_for_current_user() # Root cause addressed — not just the reported symptom
Failure Mode 2: Environment Mismatch — Fix Not in Production
# Verification checklist the developer forgot: # Developer actions taken: # [✓] Applied fix in feature branch # [✓] Merged to main # [✓] Deployed to staging # [✓] Tested on staging — fix works # [✗] Deployed to production ← MISSING # How this happens: # - Deployment to production requires separate approval (developer doesn't have it) # - Production deployment is scheduled for next release cycle (2 weeks away) # - CI/CD pipeline has a manual gate before production that wasn't triggered # - Hotfix process requires security sign-off that wasn't initiated # - Infrastructure-as-code change was merged but terraform apply wasn't run # - Docker image was rebuilt but Kubernetes deployment wasn't updated # Retest protocol: ALWAYS verify in production # The retest agreement must specify: retest against production environment # Check deployment timestamps: when was this version last deployed to prod? # Verification before requesting retest: git log --oneline -5 # Confirm fix commit kubectl get deployment myapp -o jsonpath='{.spec.template.spec.containers[0].image}' # Confirm the image tag in production matches the image containing the fix curl <https://api.company.com/api/version> # Confirm application reports the expected version
# Verification checklist the developer forgot: # Developer actions taken: # [✓] Applied fix in feature branch # [✓] Merged to main # [✓] Deployed to staging # [✓] Tested on staging — fix works # [✗] Deployed to production ← MISSING # How this happens: # - Deployment to production requires separate approval (developer doesn't have it) # - Production deployment is scheduled for next release cycle (2 weeks away) # - CI/CD pipeline has a manual gate before production that wasn't triggered # - Hotfix process requires security sign-off that wasn't initiated # - Infrastructure-as-code change was merged but terraform apply wasn't run # - Docker image was rebuilt but Kubernetes deployment wasn't updated # Retest protocol: ALWAYS verify in production # The retest agreement must specify: retest against production environment # Check deployment timestamps: when was this version last deployed to prod? # Verification before requesting retest: git log --oneline -5 # Confirm fix commit kubectl get deployment myapp -o jsonpath='{.spec.template.spec.containers[0].image}' # Confirm the image tag in production matches the image containing the fix curl <https://api.company.com/api/version> # Confirm application reports the expected version
# Verification checklist the developer forgot: # Developer actions taken: # [✓] Applied fix in feature branch # [✓] Merged to main # [✓] Deployed to staging # [✓] Tested on staging — fix works # [✗] Deployed to production ← MISSING # How this happens: # - Deployment to production requires separate approval (developer doesn't have it) # - Production deployment is scheduled for next release cycle (2 weeks away) # - CI/CD pipeline has a manual gate before production that wasn't triggered # - Hotfix process requires security sign-off that wasn't initiated # - Infrastructure-as-code change was merged but terraform apply wasn't run # - Docker image was rebuilt but Kubernetes deployment wasn't updated # Retest protocol: ALWAYS verify in production # The retest agreement must specify: retest against production environment # Check deployment timestamps: when was this version last deployed to prod? # Verification before requesting retest: git log --oneline -5 # Confirm fix commit kubectl get deployment myapp -o jsonpath='{.spec.template.spec.containers[0].image}' # Confirm the image tag in production matches the image containing the fix curl <https://api.company.com/api/version> # Confirm application reports the expected version
Failure Mode 3: Fix Bypassed by Variation
The fix addresses the exact payload in the original report but fails for variations that achieve the same outcome:
# Original finding — CORS misconfiguration # Original test request: GET /api/v1/users/profile HTTP/1.1 Origin: <https://evil.attacker.com> # Original response (vulnerable): Access-Control-Allow-Origin: <https://evil.attacker.com> # Developer's fix: explicitly check for "attacker" in origin and reject if 'attacker' in origin: reject() else: allow(origin) # Retest of original payload — PASSES: GET /api/v1/users/profile HTTP/1.1 Origin: <https://evil.attacker.com> → 403 Forbidden (fix works for this specific origin) # Retest variation — FAILS: GET /api/v1/users/profile HTTP/1.1 Origin: <https://evil.pwned.com> ← Different domain, no "attacker" → Access-Control-Allow-Origin: <https://evil.pwned.com>
# Original finding — CORS misconfiguration # Original test request: GET /api/v1/users/profile HTTP/1.1 Origin: <https://evil.attacker.com> # Original response (vulnerable): Access-Control-Allow-Origin: <https://evil.attacker.com> # Developer's fix: explicitly check for "attacker" in origin and reject if 'attacker' in origin: reject() else: allow(origin) # Retest of original payload — PASSES: GET /api/v1/users/profile HTTP/1.1 Origin: <https://evil.attacker.com> → 403 Forbidden (fix works for this specific origin) # Retest variation — FAILS: GET /api/v1/users/profile HTTP/1.1 Origin: <https://evil.pwned.com> ← Different domain, no "attacker" → Access-Control-Allow-Origin: <https://evil.pwned.com>
# Original finding — CORS misconfiguration # Original test request: GET /api/v1/users/profile HTTP/1.1 Origin: <https://evil.attacker.com> # Original response (vulnerable): Access-Control-Allow-Origin: <https://evil.attacker.com> # Developer's fix: explicitly check for "attacker" in origin and reject if 'attacker' in origin: reject() else: allow(origin) # Retest of original payload — PASSES: GET /api/v1/users/profile HTTP/1.1 Origin: <https://evil.attacker.com> → 403 Forbidden (fix works for this specific origin) # Retest variation — FAILS: GET /api/v1/users/profile HTTP/1.1 Origin: <https://evil.pwned.com> ← Different domain, no "attacker" → Access-Control-Allow-Origin: <https://evil.pwned.com>
# More variation examples that catch incomplete remediations: # SQL injection fix variation testing: # Original payload: ' OR '1'='1 # Fixed against: ' OR '1'='1 # Fails against: ' OR 1=1-- # '; WAITFOR DELAY '0:0:5'-- # ' UNION SELECT NULL-- # admin'-- # JWT bypass fix variation testing: # Original test: alg:none with empty signature # Fixed against: alg:none # Fails against: alg:None (capitalized) # alg:NONE (all caps) # alg:nOnE (mixed case) # Path traversal fix variation testing: # Original payload: ../../../etc/passwd # Fixed against: ../../../etc/passwd # Fails against: ..%2F..%2F..%2Fetc%2Fpasswd (URL encoded) # ..%252F..%252Fetc%252Fpasswd (double encoded) # ..%c0%af..%c0%afetc%c0%afpasswd (overlong UTF-8)
# More variation examples that catch incomplete remediations: # SQL injection fix variation testing: # Original payload: ' OR '1'='1 # Fixed against: ' OR '1'='1 # Fails against: ' OR 1=1-- # '; WAITFOR DELAY '0:0:5'-- # ' UNION SELECT NULL-- # admin'-- # JWT bypass fix variation testing: # Original test: alg:none with empty signature # Fixed against: alg:none # Fails against: alg:None (capitalized) # alg:NONE (all caps) # alg:nOnE (mixed case) # Path traversal fix variation testing: # Original payload: ../../../etc/passwd # Fixed against: ../../../etc/passwd # Fails against: ..%2F..%2F..%2Fetc%2Fpasswd (URL encoded) # ..%252F..%252Fetc%252Fpasswd (double encoded) # ..%c0%af..%c0%afetc%c0%afpasswd (overlong UTF-8)
# More variation examples that catch incomplete remediations: # SQL injection fix variation testing: # Original payload: ' OR '1'='1 # Fixed against: ' OR '1'='1 # Fails against: ' OR 1=1-- # '; WAITFOR DELAY '0:0:5'-- # ' UNION SELECT NULL-- # admin'-- # JWT bypass fix variation testing: # Original test: alg:none with empty signature # Fixed against: alg:none # Fails against: alg:None (capitalized) # alg:NONE (all caps) # alg:nOnE (mixed case) # Path traversal fix variation testing: # Original payload: ../../../etc/passwd # Fixed against: ../../../etc/passwd # Fails against: ..%2F..%2F..%2Fetc%2Fpasswd (URL encoded) # ..%252F..%252Fetc%252Fpasswd (double encoded) # ..%c0%af..%c0%afetc%c0%afpasswd (overlong UTF-8)
Failure Mode 4: Fix Introduces New Vulnerability
Less common but documented — the remediation introduces a different vulnerability:
# Original finding: SQL injection via unsanitized user input # WRONG fix: Input sanitization (security through sanitization) def get_user(username): # Developer sanitizes by removing special characters safe_username = re.sub(r"['\\";]", "", username) query = f"SELECT * FROM users WHERE username = '{safe_username}'" return db.execute(query) # This "fixes" the SQL injection (partially) but: # 1. Still vulnerable to some SQL injection variants (comments, UNION, etc.) # 2. Breaks legitimate usernames with apostrophes ("O'Brien") # 3. Introduces a second vulnerability: if san_username is logged, # the original (un-sanitized) input might be stored/displayed elsewhere # CORRECT fix: Parameterized queries (actual fix) def get_user(username): query = "SELECT * FROM users WHERE username = %s" return db.execute(query, (username,)) # Database handles escaping # But the wrong fix creates a different attack surface: # What if the sanitization is applied inconsistently? # What if there's a code path that bypasses the sanitization?
# Original finding: SQL injection via unsanitized user input # WRONG fix: Input sanitization (security through sanitization) def get_user(username): # Developer sanitizes by removing special characters safe_username = re.sub(r"['\\";]", "", username) query = f"SELECT * FROM users WHERE username = '{safe_username}'" return db.execute(query) # This "fixes" the SQL injection (partially) but: # 1. Still vulnerable to some SQL injection variants (comments, UNION, etc.) # 2. Breaks legitimate usernames with apostrophes ("O'Brien") # 3. Introduces a second vulnerability: if san_username is logged, # the original (un-sanitized) input might be stored/displayed elsewhere # CORRECT fix: Parameterized queries (actual fix) def get_user(username): query = "SELECT * FROM users WHERE username = %s" return db.execute(query, (username,)) # Database handles escaping # But the wrong fix creates a different attack surface: # What if the sanitization is applied inconsistently? # What if there's a code path that bypasses the sanitization?
# Original finding: SQL injection via unsanitized user input # WRONG fix: Input sanitization (security through sanitization) def get_user(username): # Developer sanitizes by removing special characters safe_username = re.sub(r"['\\";]", "", username) query = f"SELECT * FROM users WHERE username = '{safe_username}'" return db.execute(query) # This "fixes" the SQL injection (partially) but: # 1. Still vulnerable to some SQL injection variants (comments, UNION, etc.) # 2. Breaks legitimate usernames with apostrophes ("O'Brien") # 3. Introduces a second vulnerability: if san_username is logged, # the original (un-sanitized) input might be stored/displayed elsewhere # CORRECT fix: Parameterized queries (actual fix) def get_user(username): query = "SELECT * FROM users WHERE username = %s" return db.execute(query, (username,)) # Database handles escaping # But the wrong fix creates a different attack surface: # What if the sanitization is applied inconsistently? # What if there's a code path that bypasses the sanitization?
Failure Mode 5: Fix Applied in Code But Not in Infrastructure
Infrastructure-level vulnerabilities require infrastructure-level fixes:
# Original finding: Spring Boot Actuator /actuator/env exposed publicly # Developer's code fix (WRONG approach for infrastructure issue): # Added @PreAuthorize to the actuator endpoints in application config management: endpoints: web: exposure: include: "env,health,info" # But the cloud provider's load balancer / API gateway had: # /actuator/* → ALLOW (no authentication at the gateway level) # The application-level auth works for direct requests # But the gateway/WAF passes through /actuator/* before auth runs # Retest result: /actuator/env still returns 200 with all environment variables # Because the gateway routes around the application's auth middleware # CORRECT fix: Application config + infrastructure change # Step 1: Application config — restrict endpoints management: endpoints: web: exposure: include: "health,info" # Only safe endpoints server: port: 8081 # Internal port only # Step 2: Infrastructure config — block at load balancer level # AWS ALB rule: deny /actuator/* except /actuator/health, /actuator/info # Or: actuator port 8081 not exposed in security group
# Original finding: Spring Boot Actuator /actuator/env exposed publicly # Developer's code fix (WRONG approach for infrastructure issue): # Added @PreAuthorize to the actuator endpoints in application config management: endpoints: web: exposure: include: "env,health,info" # But the cloud provider's load balancer / API gateway had: # /actuator/* → ALLOW (no authentication at the gateway level) # The application-level auth works for direct requests # But the gateway/WAF passes through /actuator/* before auth runs # Retest result: /actuator/env still returns 200 with all environment variables # Because the gateway routes around the application's auth middleware # CORRECT fix: Application config + infrastructure change # Step 1: Application config — restrict endpoints management: endpoints: web: exposure: include: "health,info" # Only safe endpoints server: port: 8081 # Internal port only # Step 2: Infrastructure config — block at load balancer level # AWS ALB rule: deny /actuator/* except /actuator/health, /actuator/info # Or: actuator port 8081 not exposed in security group
# Original finding: Spring Boot Actuator /actuator/env exposed publicly # Developer's code fix (WRONG approach for infrastructure issue): # Added @PreAuthorize to the actuator endpoints in application config management: endpoints: web: exposure: include: "env,health,info" # But the cloud provider's load balancer / API gateway had: # /actuator/* → ALLOW (no authentication at the gateway level) # The application-level auth works for direct requests # But the gateway/WAF passes through /actuator/* before auth runs # Retest result: /actuator/env still returns 200 with all environment variables # Because the gateway routes around the application's auth middleware # CORRECT fix: Application config + infrastructure change # Step 1: Application config — restrict endpoints management: endpoints: web: exposure: include: "health,info" # Only safe endpoints server: port: 8081 # Internal port only # Step 2: Infrastructure config — block at load balancer level # AWS ALB rule: deny /actuator/* except /actuator/health, /actuator/info # Or: actuator port 8081 not exposed in security group
Failure Mode 6: Time-Based Conditions — Fix Not Persistent
# Original finding: Race condition on wallet withdrawal # Fixed with: database-level SELECT FOR UPDATE locking # But the fix was applied inside a transaction that has # auto-commit set to True in the ORM configuration: # WRONG fix — locking doesn't persist across auto-commit transactions def withdraw_funds(user_id, amount): with db.session() as session: # SELECT FOR UPDATE acquires lock wallet = session.query(Wallet).filter_by( user_id=user_id ).with_for_update().first() if wallet.balance >= amount: wallet.balance -= amount session.commit() # Auto-commit releases the lock # Another transaction can now also pass the balance check # before this commit is visible # CORRECT fix — lock must be held across the entire check-and-update: def withdraw_funds(user_id, amount): with db.session.begin(): # Explicit transaction boundary wallet = db.session.query(Wallet).filter_by( user_id=user_id ).with_for_update().first() # Lock acquired if wallet.balance >= amount: wallet.balance -= amount # commit() called implicitly at end of `with` block # Lock held until commit else: raise InsufficientFundsError()
# Original finding: Race condition on wallet withdrawal # Fixed with: database-level SELECT FOR UPDATE locking # But the fix was applied inside a transaction that has # auto-commit set to True in the ORM configuration: # WRONG fix — locking doesn't persist across auto-commit transactions def withdraw_funds(user_id, amount): with db.session() as session: # SELECT FOR UPDATE acquires lock wallet = session.query(Wallet).filter_by( user_id=user_id ).with_for_update().first() if wallet.balance >= amount: wallet.balance -= amount session.commit() # Auto-commit releases the lock # Another transaction can now also pass the balance check # before this commit is visible # CORRECT fix — lock must be held across the entire check-and-update: def withdraw_funds(user_id, amount): with db.session.begin(): # Explicit transaction boundary wallet = db.session.query(Wallet).filter_by( user_id=user_id ).with_for_update().first() # Lock acquired if wallet.balance >= amount: wallet.balance -= amount # commit() called implicitly at end of `with` block # Lock held until commit else: raise InsufficientFundsError()
# Original finding: Race condition on wallet withdrawal # Fixed with: database-level SELECT FOR UPDATE locking # But the fix was applied inside a transaction that has # auto-commit set to True in the ORM configuration: # WRONG fix — locking doesn't persist across auto-commit transactions def withdraw_funds(user_id, amount): with db.session() as session: # SELECT FOR UPDATE acquires lock wallet = session.query(Wallet).filter_by( user_id=user_id ).with_for_update().first() if wallet.balance >= amount: wallet.balance -= amount session.commit() # Auto-commit releases the lock # Another transaction can now also pass the balance check # before this commit is visible # CORRECT fix — lock must be held across the entire check-and-update: def withdraw_funds(user_id, amount): with db.session.begin(): # Explicit transaction boundary wallet = db.session.query(Wallet).filter_by( user_id=user_id ).with_for_update().first() # Lock acquired if wallet.balance >= amount: wallet.balance -= amount # commit() called implicitly at end of `with` block # Lock held until commit else: raise InsufficientFundsError()
The Retest Process: What Actually Happens
Pre-Retest Requirements
Before a retest begins, specific conditions must be confirmed:
The Retest Execution Protocol
For each original finding, the retest follows a structured protocol:
class RetestProtocol: """ Structured retest execution for each finding. """ def retest_finding(self, finding: dict, target_env: str) -> dict: """ Execute retest for a single finding. Returns: retest result with pass/fail/partial determination """ result = { 'finding_id': finding['id'], 'finding_title': finding['title'], 'original_cvss': finding['cvss'], 'retest_date': datetime.now().isoformat(), 'target_environment': target_env, 'steps_executed': [], 'status': None, # 'REMEDIATED' | 'PARTIALLY_REMEDIATED' | 'NOT_REMEDIATED' 'new_cvss': None, 'notes': [] } # Step 1: Reproduce original exploit original_exploit_result = self.execute_original_exploit(finding) result['steps_executed'].append({ 'step': 'original_exploit_reproduction', 'description': 'Execute exact attack vector from original finding', 'payload': finding['exploit']['payload'], 'expected_outcome': '403/401/400 (vulnerability fixed)', 'actual_outcome': original_exploit_result['status_code'], 'passed': original_exploit_result['status_code'] in [401, 403, 400] }) if not result['steps_executed'][-1]['passed']: # Original exploit still works — finding NOT remediated result['status'] = 'NOT_REMEDIATED' result['new_cvss'] = finding['cvss'] # Unchanged result['notes'].append('Original exploit vector still active') return result # Step 2: Variation testing — test related attack vectors variation_results = [] for variation in self.generate_variations(finding): var_result = self.execute_variation(variation, finding) variation_results.append({ 'variation': variation['name'], 'payload': variation['payload'], 'passed': var_result['status_code'] in [401, 403, 400], 'actual_outcome': var_result['status_code'] }) failed_variations = [v for v in variation_results if not v['passed']] result['steps_executed'].extend(variation_results) # Step 3: Root cause verification root_cause_verified = self.verify_root_cause_fix(finding) result['steps_executed'].append({ 'step': 'root_cause_verification', 'description': 'Verify the underlying vulnerability class is addressed', 'passed': root_cause_verified }) # Determine overall status if not failed_variations and root_cause_verified: result['status'] = 'REMEDIATED' result['new_cvss'] = 0.0 result['notes'].append('All exploit vectors confirmed fixed') elif failed_variations and not root_cause_verified: result['status'] = 'NOT_REMEDIATED' result['new_cvss'] = finding['cvss'] result['notes'].append( f'Variation testing failed: {[v["variation"] for v in failed_variations]}' ) else: # Original fixed but variations fail — partial remediation result['status'] = 'PARTIALLY_REMEDIATED' # Adjust CVSS based on remaining attack surface result['new_cvss'] = self.calculate_adjusted_cvss( finding, failed_variations ) result['notes'].append( 'Original vector fixed but related vulnerabilities remain' ) return result def generate_variations(self, finding: dict) -> list: """Generate variation test cases based on finding type""" finding_type = finding['type'] variations = [] if finding_type == 'IDOR': # Test adjacent endpoints following same pattern base_endpoint = finding['exploit']['endpoint'] variations = [ {'name': 'sub_resource', 'payload': f"{base_endpoint}/details"}, {'name': 'list_endpoint', 'payload': base_endpoint.replace('{id}', '').rstrip('/')}, {'name': 'update_method', 'method': 'PUT', 'payload': base_endpoint}, {'name': 'delete_method', 'method': 'DELETE', 'payload': base_endpoint}, ] elif finding_type == 'SQL_INJECTION': # Test payload variations original_payload = finding['exploit']['payload'] variations = [ {'name': 'comment_style', 'payload': "' OR 1=1--"}, {'name': 'union_based', 'payload': "' UNION SELECT NULL--"}, {'name': 'time_based', 'payload': "'; WAITFOR DELAY '0:0:5'--"}, {'name': 'stacked_query', 'payload': "'; DROP TABLE--"}, {'name': 'double_encoded', 'payload': original_payload.replace("'", "%2527")}, ] elif finding_type == 'CORS_MISCONFIGURATION': variations = [ {'name': 'different_attacker_domain', 'origin': '<https://different-attacker.net>'}, {'name': 'null_origin', 'origin': 'null'}, {'name': 'subdomain_bypass', 'origin': f"<https://evil>.{finding['target_domain']}"}, {'name': 'http_protocol', 'origin': finding['allowed_origin'].replace('https://', 'http://')}, ] return variations
class RetestProtocol: """ Structured retest execution for each finding. """ def retest_finding(self, finding: dict, target_env: str) -> dict: """ Execute retest for a single finding. Returns: retest result with pass/fail/partial determination """ result = { 'finding_id': finding['id'], 'finding_title': finding['title'], 'original_cvss': finding['cvss'], 'retest_date': datetime.now().isoformat(), 'target_environment': target_env, 'steps_executed': [], 'status': None, # 'REMEDIATED' | 'PARTIALLY_REMEDIATED' | 'NOT_REMEDIATED' 'new_cvss': None, 'notes': [] } # Step 1: Reproduce original exploit original_exploit_result = self.execute_original_exploit(finding) result['steps_executed'].append({ 'step': 'original_exploit_reproduction', 'description': 'Execute exact attack vector from original finding', 'payload': finding['exploit']['payload'], 'expected_outcome': '403/401/400 (vulnerability fixed)', 'actual_outcome': original_exploit_result['status_code'], 'passed': original_exploit_result['status_code'] in [401, 403, 400] }) if not result['steps_executed'][-1]['passed']: # Original exploit still works — finding NOT remediated result['status'] = 'NOT_REMEDIATED' result['new_cvss'] = finding['cvss'] # Unchanged result['notes'].append('Original exploit vector still active') return result # Step 2: Variation testing — test related attack vectors variation_results = [] for variation in self.generate_variations(finding): var_result = self.execute_variation(variation, finding) variation_results.append({ 'variation': variation['name'], 'payload': variation['payload'], 'passed': var_result['status_code'] in [401, 403, 400], 'actual_outcome': var_result['status_code'] }) failed_variations = [v for v in variation_results if not v['passed']] result['steps_executed'].extend(variation_results) # Step 3: Root cause verification root_cause_verified = self.verify_root_cause_fix(finding) result['steps_executed'].append({ 'step': 'root_cause_verification', 'description': 'Verify the underlying vulnerability class is addressed', 'passed': root_cause_verified }) # Determine overall status if not failed_variations and root_cause_verified: result['status'] = 'REMEDIATED' result['new_cvss'] = 0.0 result['notes'].append('All exploit vectors confirmed fixed') elif failed_variations and not root_cause_verified: result['status'] = 'NOT_REMEDIATED' result['new_cvss'] = finding['cvss'] result['notes'].append( f'Variation testing failed: {[v["variation"] for v in failed_variations]}' ) else: # Original fixed but variations fail — partial remediation result['status'] = 'PARTIALLY_REMEDIATED' # Adjust CVSS based on remaining attack surface result['new_cvss'] = self.calculate_adjusted_cvss( finding, failed_variations ) result['notes'].append( 'Original vector fixed but related vulnerabilities remain' ) return result def generate_variations(self, finding: dict) -> list: """Generate variation test cases based on finding type""" finding_type = finding['type'] variations = [] if finding_type == 'IDOR': # Test adjacent endpoints following same pattern base_endpoint = finding['exploit']['endpoint'] variations = [ {'name': 'sub_resource', 'payload': f"{base_endpoint}/details"}, {'name': 'list_endpoint', 'payload': base_endpoint.replace('{id}', '').rstrip('/')}, {'name': 'update_method', 'method': 'PUT', 'payload': base_endpoint}, {'name': 'delete_method', 'method': 'DELETE', 'payload': base_endpoint}, ] elif finding_type == 'SQL_INJECTION': # Test payload variations original_payload = finding['exploit']['payload'] variations = [ {'name': 'comment_style', 'payload': "' OR 1=1--"}, {'name': 'union_based', 'payload': "' UNION SELECT NULL--"}, {'name': 'time_based', 'payload': "'; WAITFOR DELAY '0:0:5'--"}, {'name': 'stacked_query', 'payload': "'; DROP TABLE--"}, {'name': 'double_encoded', 'payload': original_payload.replace("'", "%2527")}, ] elif finding_type == 'CORS_MISCONFIGURATION': variations = [ {'name': 'different_attacker_domain', 'origin': '<https://different-attacker.net>'}, {'name': 'null_origin', 'origin': 'null'}, {'name': 'subdomain_bypass', 'origin': f"<https://evil>.{finding['target_domain']}"}, {'name': 'http_protocol', 'origin': finding['allowed_origin'].replace('https://', 'http://')}, ] return variations
class RetestProtocol: """ Structured retest execution for each finding. """ def retest_finding(self, finding: dict, target_env: str) -> dict: """ Execute retest for a single finding. Returns: retest result with pass/fail/partial determination """ result = { 'finding_id': finding['id'], 'finding_title': finding['title'], 'original_cvss': finding['cvss'], 'retest_date': datetime.now().isoformat(), 'target_environment': target_env, 'steps_executed': [], 'status': None, # 'REMEDIATED' | 'PARTIALLY_REMEDIATED' | 'NOT_REMEDIATED' 'new_cvss': None, 'notes': [] } # Step 1: Reproduce original exploit original_exploit_result = self.execute_original_exploit(finding) result['steps_executed'].append({ 'step': 'original_exploit_reproduction', 'description': 'Execute exact attack vector from original finding', 'payload': finding['exploit']['payload'], 'expected_outcome': '403/401/400 (vulnerability fixed)', 'actual_outcome': original_exploit_result['status_code'], 'passed': original_exploit_result['status_code'] in [401, 403, 400] }) if not result['steps_executed'][-1]['passed']: # Original exploit still works — finding NOT remediated result['status'] = 'NOT_REMEDIATED' result['new_cvss'] = finding['cvss'] # Unchanged result['notes'].append('Original exploit vector still active') return result # Step 2: Variation testing — test related attack vectors variation_results = [] for variation in self.generate_variations(finding): var_result = self.execute_variation(variation, finding) variation_results.append({ 'variation': variation['name'], 'payload': variation['payload'], 'passed': var_result['status_code'] in [401, 403, 400], 'actual_outcome': var_result['status_code'] }) failed_variations = [v for v in variation_results if not v['passed']] result['steps_executed'].extend(variation_results) # Step 3: Root cause verification root_cause_verified = self.verify_root_cause_fix(finding) result['steps_executed'].append({ 'step': 'root_cause_verification', 'description': 'Verify the underlying vulnerability class is addressed', 'passed': root_cause_verified }) # Determine overall status if not failed_variations and root_cause_verified: result['status'] = 'REMEDIATED' result['new_cvss'] = 0.0 result['notes'].append('All exploit vectors confirmed fixed') elif failed_variations and not root_cause_verified: result['status'] = 'NOT_REMEDIATED' result['new_cvss'] = finding['cvss'] result['notes'].append( f'Variation testing failed: {[v["variation"] for v in failed_variations]}' ) else: # Original fixed but variations fail — partial remediation result['status'] = 'PARTIALLY_REMEDIATED' # Adjust CVSS based on remaining attack surface result['new_cvss'] = self.calculate_adjusted_cvss( finding, failed_variations ) result['notes'].append( 'Original vector fixed but related vulnerabilities remain' ) return result def generate_variations(self, finding: dict) -> list: """Generate variation test cases based on finding type""" finding_type = finding['type'] variations = [] if finding_type == 'IDOR': # Test adjacent endpoints following same pattern base_endpoint = finding['exploit']['endpoint'] variations = [ {'name': 'sub_resource', 'payload': f"{base_endpoint}/details"}, {'name': 'list_endpoint', 'payload': base_endpoint.replace('{id}', '').rstrip('/')}, {'name': 'update_method', 'method': 'PUT', 'payload': base_endpoint}, {'name': 'delete_method', 'method': 'DELETE', 'payload': base_endpoint}, ] elif finding_type == 'SQL_INJECTION': # Test payload variations original_payload = finding['exploit']['payload'] variations = [ {'name': 'comment_style', 'payload': "' OR 1=1--"}, {'name': 'union_based', 'payload': "' UNION SELECT NULL--"}, {'name': 'time_based', 'payload': "'; WAITFOR DELAY '0:0:5'--"}, {'name': 'stacked_query', 'payload': "'; DROP TABLE--"}, {'name': 'double_encoded', 'payload': original_payload.replace("'", "%2527")}, ] elif finding_type == 'CORS_MISCONFIGURATION': variations = [ {'name': 'different_attacker_domain', 'origin': '<https://different-attacker.net>'}, {'name': 'null_origin', 'origin': 'null'}, {'name': 'subdomain_bypass', 'origin': f"<https://evil>.{finding['target_domain']}"}, {'name': 'http_protocol', 'origin': finding['allowed_origin'].replace('https://', 'http://')}, ] return variations
What Auditors Actually Want: Compliance Evidence Requirements
SOC 2 Type II Retest Evidence Requirements
SOC 2 Type II requires evidence of controls operating effectively over the audit period. For penetration testing, auditors look for:
PCI-DSS Penetration Testing Requirements
PCI-DSS has the most specific penetration testing requirements of any compliance framework:
ISO 27001 Evidence Requirements
Building the Remediation Evidence Package
The Per-Finding Evidence Template
For each finding in the original report, the remediation evidence package should contain:
# Finding Remediation Evidence ## Finding Reference - **Finding ID:** FIND-2026-001 - **Title:** IDOR — Authenticated Cross-User Document Access - **Original CVSS:** 8.3 (High) - **Original Discovery Date:** 2026-02-15 - **Remediation Target Date:** 2026-02-22 (7-day SLA for High findings) ## Root Cause Analysis The `/api/v1/documents/{id}` endpoint retrieved documents by ID without verifying that the document belongs to the authenticated user. The ORM query was: `Document.query.get(doc_id)` with no ownership filter. ## Remediation Applied **What changed:** - File: `app/api/documents/views.py` - Function: `get_document()` (line 47) - Before: `doc = Document.query.get(doc_id)` - After: `doc = Document.query.filter_by(id=doc_id, user_id=current_user.id).first_or_404()` **Scope of fix:** - Applied the same ownership filter pattern to all Document endpoints: GET /api/v1/documents/{id} PUT /api/v1/documents/{id} DELETE /api/v1/documents/{id} GET /api/v1/documents/{id}/versions - Implemented DocumentQuery.for_current_user() helper to prevent recurrence **Pull Request:** <https://github.com/company/app/pull/1842> **Code Review Approval:** Approved by: Jane Smith (2026-02-20) **Test Coverage:** Unit tests added in test_documents.py lines 120-145 ## Deployment Evidence - **Environment:** Production (api.company.com) - **Deployment Date:** 2026-02-21 14:33 UTC - **Deployed By:** CI/CD pipeline (trigger: merge to main) - **Build ID:** build-20260221-1433 - **Version:** v2.14.1 - **Deployment Log:** <https://ci.company.com/builds/20260221-1433> ## Self-Test Results Engineer self-test performed 2026-02-21 16:00: GET /api/v1/documents/[other_user_doc_id] → 404 Not Found ✓ GET /api/v1/documents/[own_doc_id] → 200 OK ✓ ## Retest Status **Awaiting independent retest**
# Finding Remediation Evidence ## Finding Reference - **Finding ID:** FIND-2026-001 - **Title:** IDOR — Authenticated Cross-User Document Access - **Original CVSS:** 8.3 (High) - **Original Discovery Date:** 2026-02-15 - **Remediation Target Date:** 2026-02-22 (7-day SLA for High findings) ## Root Cause Analysis The `/api/v1/documents/{id}` endpoint retrieved documents by ID without verifying that the document belongs to the authenticated user. The ORM query was: `Document.query.get(doc_id)` with no ownership filter. ## Remediation Applied **What changed:** - File: `app/api/documents/views.py` - Function: `get_document()` (line 47) - Before: `doc = Document.query.get(doc_id)` - After: `doc = Document.query.filter_by(id=doc_id, user_id=current_user.id).first_or_404()` **Scope of fix:** - Applied the same ownership filter pattern to all Document endpoints: GET /api/v1/documents/{id} PUT /api/v1/documents/{id} DELETE /api/v1/documents/{id} GET /api/v1/documents/{id}/versions - Implemented DocumentQuery.for_current_user() helper to prevent recurrence **Pull Request:** <https://github.com/company/app/pull/1842> **Code Review Approval:** Approved by: Jane Smith (2026-02-20) **Test Coverage:** Unit tests added in test_documents.py lines 120-145 ## Deployment Evidence - **Environment:** Production (api.company.com) - **Deployment Date:** 2026-02-21 14:33 UTC - **Deployed By:** CI/CD pipeline (trigger: merge to main) - **Build ID:** build-20260221-1433 - **Version:** v2.14.1 - **Deployment Log:** <https://ci.company.com/builds/20260221-1433> ## Self-Test Results Engineer self-test performed 2026-02-21 16:00: GET /api/v1/documents/[other_user_doc_id] → 404 Not Found ✓ GET /api/v1/documents/[own_doc_id] → 200 OK ✓ ## Retest Status **Awaiting independent retest**
# Finding Remediation Evidence ## Finding Reference - **Finding ID:** FIND-2026-001 - **Title:** IDOR — Authenticated Cross-User Document Access - **Original CVSS:** 8.3 (High) - **Original Discovery Date:** 2026-02-15 - **Remediation Target Date:** 2026-02-22 (7-day SLA for High findings) ## Root Cause Analysis The `/api/v1/documents/{id}` endpoint retrieved documents by ID without verifying that the document belongs to the authenticated user. The ORM query was: `Document.query.get(doc_id)` with no ownership filter. ## Remediation Applied **What changed:** - File: `app/api/documents/views.py` - Function: `get_document()` (line 47) - Before: `doc = Document.query.get(doc_id)` - After: `doc = Document.query.filter_by(id=doc_id, user_id=current_user.id).first_or_404()` **Scope of fix:** - Applied the same ownership filter pattern to all Document endpoints: GET /api/v1/documents/{id} PUT /api/v1/documents/{id} DELETE /api/v1/documents/{id} GET /api/v1/documents/{id}/versions - Implemented DocumentQuery.for_current_user() helper to prevent recurrence **Pull Request:** <https://github.com/company/app/pull/1842> **Code Review Approval:** Approved by: Jane Smith (2026-02-20) **Test Coverage:** Unit tests added in test_documents.py lines 120-145 ## Deployment Evidence - **Environment:** Production (api.company.com) - **Deployment Date:** 2026-02-21 14:33 UTC - **Deployed By:** CI/CD pipeline (trigger: merge to main) - **Build ID:** build-20260221-1433 - **Version:** v2.14.1 - **Deployment Log:** <https://ci.company.com/builds/20260221-1433> ## Self-Test Results Engineer self-test performed 2026-02-21 16:00: GET /api/v1/documents/[other_user_doc_id] → 404 Not Found ✓ GET /api/v1/documents/[own_doc_id] → 200 OK ✓ ## Retest Status **Awaiting independent retest**
The Retest Report Structure
The final retest report that goes to auditors:
CVSS Delta Reporting: Quantifying Risk Reduction
One of the most valuable outputs of a professional retest is CVSS delta reporting — the quantifiable change in risk posture from the original test to the retest:
def calculate_cvss_delta_report(original_findings: list, retest_results: list) -> dict: """ Calculate the CVSS delta between original test and retest. Provides quantifiable evidence of risk reduction for compliance. """ # Build lookup by finding ID original_by_id = {f['id']: f for f in original_findings} retest_by_id = {r['finding_id']: r for r in retest_results} # Calculate aggregate scores original_total_cvss = sum(f['cvss'] for f in original_findings) remediated_cvss = 0 partially_remediated_cvss = 0 not_remediated_cvss = 0 finding_details = [] for finding_id, original in original_by_id.items(): retest = retest_by_id.get(finding_id, {}) status = retest.get('status', 'NOT_RETESTED') if status == 'REMEDIATED': new_cvss = 0.0 remediated_cvss += original['cvss'] elif status == 'PARTIALLY_REMEDIATED': new_cvss = retest.get('new_cvss', original['cvss'] * 0.5) partially_remediated_cvss += new_cvss else: new_cvss = original['cvss'] not_remediated_cvss += new_cvss finding_details.append({ 'id': finding_id, 'title': original['title'], 'original_cvss': original['cvss'], 'original_severity': original['severity'], 'status': status, 'new_cvss': new_cvss, 'cvss_delta': original['cvss'] - new_cvss, 'risk_eliminated': status == 'REMEDIATED' }) post_retest_total = partially_remediated_cvss + not_remediated_cvss risk_reduction_pct = ((original_total_cvss - post_retest_total) / original_total_cvss * 100) if original_total_cvss > 0 else 0 return { 'original_test_date': original_findings[0].get('test_date'), 'retest_date': retest_results[0].get('retest_date') if retest_results else None, 'finding_counts': { 'original': len(original_findings), 'remediated': sum(1 for r in retest_results if r['status'] == 'REMEDIATED'), 'partially_remediated': sum(1 for r in retest_results if r['status'] == 'PARTIALLY_REMEDIATED'), 'not_remediated': sum(1 for r in retest_results if r['status'] == 'NOT_REMEDIATED'), }, 'cvss_aggregate': { 'original_total': round(original_total_cvss, 1), 'post_retest_total': round(post_retest_total, 1), 'risk_eliminated': round(original_total_cvss - post_retest_total, 1), 'risk_reduction_percentage': round(risk_reduction_pct, 1) }, 'severity_breakdown': { 'critical': { 'original': sum(1 for f in original_findings if f['severity'] == 'CRITICAL'), 'remaining': sum(1 for r in retest_results if r['status'] != 'REMEDIATED' and original_by_id[r['finding_id']]['severity'] == 'CRITICAL') }, 'high': { 'original': sum(1 for f in original_findings if f['severity'] == 'HIGH'), 'remaining': sum(1 for r in retest_results if r['status'] != 'REMEDIATED' and original_by_id[r['finding_id']]['severity'] == 'HIGH') }, }, 'findings': finding_details, 'compliance_summary': { 'pci_dss_compliant': not_remediated_cvss == 0 and partially_remediated_cvss == 0, 'soc2_evidence_complete': len(finding_details) == len(original_findings), 'all_critical_remediated': all( r['status'] == 'REMEDIATED' for r in retest_results if original_by_id[r['finding_id']]['severity'] == 'CRITICAL' ) } } # Example output: """ CVSS Delta Report ================= Original Test Date: 2026-02-15 Retest Date: 2026-03-01 Finding Summary: Total original findings: 12 Fully remediated: 9 Partially remediated: 2 Not remediated: 1 CVSS Aggregate: Original total CVSS: 67.4 Post-retest total CVSS: 11.2 Risk eliminated: 56.2 (83.4%) Severity Breakdown: Critical: 2 original → 0 remaining ✓ High: 4 original → 1 remaining ✗ Medium: 6 original → 2 remaining ✗ Compliance Status: PCI-DSS: NOT YET COMPLIANT (1 high finding not remediated) SOC 2: Evidence package complete for 9 remediated findings Critical findings: All remediated ✓ """
def calculate_cvss_delta_report(original_findings: list, retest_results: list) -> dict: """ Calculate the CVSS delta between original test and retest. Provides quantifiable evidence of risk reduction for compliance. """ # Build lookup by finding ID original_by_id = {f['id']: f for f in original_findings} retest_by_id = {r['finding_id']: r for r in retest_results} # Calculate aggregate scores original_total_cvss = sum(f['cvss'] for f in original_findings) remediated_cvss = 0 partially_remediated_cvss = 0 not_remediated_cvss = 0 finding_details = [] for finding_id, original in original_by_id.items(): retest = retest_by_id.get(finding_id, {}) status = retest.get('status', 'NOT_RETESTED') if status == 'REMEDIATED': new_cvss = 0.0 remediated_cvss += original['cvss'] elif status == 'PARTIALLY_REMEDIATED': new_cvss = retest.get('new_cvss', original['cvss'] * 0.5) partially_remediated_cvss += new_cvss else: new_cvss = original['cvss'] not_remediated_cvss += new_cvss finding_details.append({ 'id': finding_id, 'title': original['title'], 'original_cvss': original['cvss'], 'original_severity': original['severity'], 'status': status, 'new_cvss': new_cvss, 'cvss_delta': original['cvss'] - new_cvss, 'risk_eliminated': status == 'REMEDIATED' }) post_retest_total = partially_remediated_cvss + not_remediated_cvss risk_reduction_pct = ((original_total_cvss - post_retest_total) / original_total_cvss * 100) if original_total_cvss > 0 else 0 return { 'original_test_date': original_findings[0].get('test_date'), 'retest_date': retest_results[0].get('retest_date') if retest_results else None, 'finding_counts': { 'original': len(original_findings), 'remediated': sum(1 for r in retest_results if r['status'] == 'REMEDIATED'), 'partially_remediated': sum(1 for r in retest_results if r['status'] == 'PARTIALLY_REMEDIATED'), 'not_remediated': sum(1 for r in retest_results if r['status'] == 'NOT_REMEDIATED'), }, 'cvss_aggregate': { 'original_total': round(original_total_cvss, 1), 'post_retest_total': round(post_retest_total, 1), 'risk_eliminated': round(original_total_cvss - post_retest_total, 1), 'risk_reduction_percentage': round(risk_reduction_pct, 1) }, 'severity_breakdown': { 'critical': { 'original': sum(1 for f in original_findings if f['severity'] == 'CRITICAL'), 'remaining': sum(1 for r in retest_results if r['status'] != 'REMEDIATED' and original_by_id[r['finding_id']]['severity'] == 'CRITICAL') }, 'high': { 'original': sum(1 for f in original_findings if f['severity'] == 'HIGH'), 'remaining': sum(1 for r in retest_results if r['status'] != 'REMEDIATED' and original_by_id[r['finding_id']]['severity'] == 'HIGH') }, }, 'findings': finding_details, 'compliance_summary': { 'pci_dss_compliant': not_remediated_cvss == 0 and partially_remediated_cvss == 0, 'soc2_evidence_complete': len(finding_details) == len(original_findings), 'all_critical_remediated': all( r['status'] == 'REMEDIATED' for r in retest_results if original_by_id[r['finding_id']]['severity'] == 'CRITICAL' ) } } # Example output: """ CVSS Delta Report ================= Original Test Date: 2026-02-15 Retest Date: 2026-03-01 Finding Summary: Total original findings: 12 Fully remediated: 9 Partially remediated: 2 Not remediated: 1 CVSS Aggregate: Original total CVSS: 67.4 Post-retest total CVSS: 11.2 Risk eliminated: 56.2 (83.4%) Severity Breakdown: Critical: 2 original → 0 remaining ✓ High: 4 original → 1 remaining ✗ Medium: 6 original → 2 remaining ✗ Compliance Status: PCI-DSS: NOT YET COMPLIANT (1 high finding not remediated) SOC 2: Evidence package complete for 9 remediated findings Critical findings: All remediated ✓ """
def calculate_cvss_delta_report(original_findings: list, retest_results: list) -> dict: """ Calculate the CVSS delta between original test and retest. Provides quantifiable evidence of risk reduction for compliance. """ # Build lookup by finding ID original_by_id = {f['id']: f for f in original_findings} retest_by_id = {r['finding_id']: r for r in retest_results} # Calculate aggregate scores original_total_cvss = sum(f['cvss'] for f in original_findings) remediated_cvss = 0 partially_remediated_cvss = 0 not_remediated_cvss = 0 finding_details = [] for finding_id, original in original_by_id.items(): retest = retest_by_id.get(finding_id, {}) status = retest.get('status', 'NOT_RETESTED') if status == 'REMEDIATED': new_cvss = 0.0 remediated_cvss += original['cvss'] elif status == 'PARTIALLY_REMEDIATED': new_cvss = retest.get('new_cvss', original['cvss'] * 0.5) partially_remediated_cvss += new_cvss else: new_cvss = original['cvss'] not_remediated_cvss += new_cvss finding_details.append({ 'id': finding_id, 'title': original['title'], 'original_cvss': original['cvss'], 'original_severity': original['severity'], 'status': status, 'new_cvss': new_cvss, 'cvss_delta': original['cvss'] - new_cvss, 'risk_eliminated': status == 'REMEDIATED' }) post_retest_total = partially_remediated_cvss + not_remediated_cvss risk_reduction_pct = ((original_total_cvss - post_retest_total) / original_total_cvss * 100) if original_total_cvss > 0 else 0 return { 'original_test_date': original_findings[0].get('test_date'), 'retest_date': retest_results[0].get('retest_date') if retest_results else None, 'finding_counts': { 'original': len(original_findings), 'remediated': sum(1 for r in retest_results if r['status'] == 'REMEDIATED'), 'partially_remediated': sum(1 for r in retest_results if r['status'] == 'PARTIALLY_REMEDIATED'), 'not_remediated': sum(1 for r in retest_results if r['status'] == 'NOT_REMEDIATED'), }, 'cvss_aggregate': { 'original_total': round(original_total_cvss, 1), 'post_retest_total': round(post_retest_total, 1), 'risk_eliminated': round(original_total_cvss - post_retest_total, 1), 'risk_reduction_percentage': round(risk_reduction_pct, 1) }, 'severity_breakdown': { 'critical': { 'original': sum(1 for f in original_findings if f['severity'] == 'CRITICAL'), 'remaining': sum(1 for r in retest_results if r['status'] != 'REMEDIATED' and original_by_id[r['finding_id']]['severity'] == 'CRITICAL') }, 'high': { 'original': sum(1 for f in original_findings if f['severity'] == 'HIGH'), 'remaining': sum(1 for r in retest_results if r['status'] != 'REMEDIATED' and original_by_id[r['finding_id']]['severity'] == 'HIGH') }, }, 'findings': finding_details, 'compliance_summary': { 'pci_dss_compliant': not_remediated_cvss == 0 and partially_remediated_cvss == 0, 'soc2_evidence_complete': len(finding_details) == len(original_findings), 'all_critical_remediated': all( r['status'] == 'REMEDIATED' for r in retest_results if original_by_id[r['finding_id']]['severity'] == 'CRITICAL' ) } } # Example output: """ CVSS Delta Report ================= Original Test Date: 2026-02-15 Retest Date: 2026-03-01 Finding Summary: Total original findings: 12 Fully remediated: 9 Partially remediated: 2 Not remediated: 1 CVSS Aggregate: Original total CVSS: 67.4 Post-retest total CVSS: 11.2 Risk eliminated: 56.2 (83.4%) Severity Breakdown: Critical: 2 original → 0 remaining ✓ High: 4 original → 1 remaining ✗ Medium: 6 original → 2 remaining ✗ Compliance Status: PCI-DSS: NOT YET COMPLIANT (1 high finding not remediated) SOC 2: Evidence package complete for 9 remediated findings Critical findings: All remediated ✓ """
How to Fix Findings to Pass Retest the First Time
The most valuable investment an engineering team can make is ensuring that remediations pass retest on the first attempt. This requires three practices:
Practice 1: Fix Root Cause, Not Symptom
Every finding in a pentest report documents a specific instance of a vulnerability class. The remediation must address the vulnerability class, not just the specific instance:
# Mental model for root cause vs symptom: # Finding: "SQL injection on /api/users/search?name=X" # Symptom fix: Add input sanitization to this endpoint ← FAILS variation testing # Root cause fix: Use parameterized queries everywhere ← PASSES variation testing # Finding: "IDOR on GET /api/orders/{id}" # Symptom fix: Add ownership check to GET /api/orders/{id} ← FAILS variation # Root cause fix: Implement ownership-enforced query manager for all Order operations # Finding: "JWT accepts alg:none" # Symptom fix: Check for "none" string in algorithm ← FAILS case variation # Root cause fix: Strict algorithm allowlist ['HS256'] only ← PASSES all variations # Finding: "hardcoded AWS key in JavaScript bundle" # Symptom fix: Remove the key from that file ← FAILS if pattern repeated elsewhere # Root cause fix: Remove all AWS SDK usage from frontend + implement presigned URLs # + add CI/CD secret scanning to prevent recurrence
# Mental model for root cause vs symptom: # Finding: "SQL injection on /api/users/search?name=X" # Symptom fix: Add input sanitization to this endpoint ← FAILS variation testing # Root cause fix: Use parameterized queries everywhere ← PASSES variation testing # Finding: "IDOR on GET /api/orders/{id}" # Symptom fix: Add ownership check to GET /api/orders/{id} ← FAILS variation # Root cause fix: Implement ownership-enforced query manager for all Order operations # Finding: "JWT accepts alg:none" # Symptom fix: Check for "none" string in algorithm ← FAILS case variation # Root cause fix: Strict algorithm allowlist ['HS256'] only ← PASSES all variations # Finding: "hardcoded AWS key in JavaScript bundle" # Symptom fix: Remove the key from that file ← FAILS if pattern repeated elsewhere # Root cause fix: Remove all AWS SDK usage from frontend + implement presigned URLs # + add CI/CD secret scanning to prevent recurrence
# Mental model for root cause vs symptom: # Finding: "SQL injection on /api/users/search?name=X" # Symptom fix: Add input sanitization to this endpoint ← FAILS variation testing # Root cause fix: Use parameterized queries everywhere ← PASSES variation testing # Finding: "IDOR on GET /api/orders/{id}" # Symptom fix: Add ownership check to GET /api/orders/{id} ← FAILS variation # Root cause fix: Implement ownership-enforced query manager for all Order operations # Finding: "JWT accepts alg:none" # Symptom fix: Check for "none" string in algorithm ← FAILS case variation # Root cause fix: Strict algorithm allowlist ['HS256'] only ← PASSES all variations # Finding: "hardcoded AWS key in JavaScript bundle" # Symptom fix: Remove the key from that file ← FAILS if pattern repeated elsewhere # Root cause fix: Remove all AWS SDK usage from frontend + implement presigned URLs # + add CI/CD secret scanning to prevent recurrence
Practice 2: Test Variations Before Requesting Retest
Before marking a finding as remediated and requesting retest, the engineering team should self-test the variations the retest will use:
# Self-test checklist template for common finding types: SELF_TEST_CHECKLISTS = { 'IDOR': [ 'Test original endpoint with another user\\'s ID → expect 403/404', 'Test all HTTP methods on the same endpoint (GET, PUT, DELETE)', 'Test all sub-resources of the same object', 'Test the list endpoint for the same resource type', 'Test with IDs from different tenants (if multi-tenant)', 'Test with numeric IDs adjacent to own IDs (±1, ±10)', ], 'SQL_INJECTION': [ 'Test with original payload → expect safe response', 'Test with single quote payload → expect safe response', 'Test with comment-based payload (--) → expect safe response', 'Test with UNION-based payload → expect safe response', 'Test with time-based payload (SLEEP/WAITFOR) → timing should be normal', 'Test with all input fields on the same endpoint', 'Test with same input pattern on adjacent endpoints', ], 'CORS_MISCONFIGURATION': [ 'Test with original attacker origin → expect no ACAO header or 403', 'Test with different attacker domain → expect same result', 'Test with null origin → expect no reflected null', 'Test with HTTP variant of trusted domain → expect rejection', 'Test subdomain variations of trusted domain', 'Confirm legitimate origins still work (don\\'t break production)', ], 'AUTHENTICATION_BYPASS': [ 'Test original bypass technique → expect 401/403', 'Test endpoint without any credentials → expect 401', 'Test with expired credentials → expect 401', 'Test with invalid token signature → expect 401', 'Test all HTTP methods without credentials', 'Test adjacent endpoints in same namespace', ], 'SECRETS_IN_BUNDLE': [ 'Download and search current production bundle → no secrets found', 'Search all JS files, not just main bundle (chunk files too)', 'Search source maps if still deployed', 'Verify secret has been rotated (test old secret doesn\\'t work)', 'Scan git history for the same secret (ensure it\\'s removed)', 'Run gitleaks on current codebase', ], }
# Self-test checklist template for common finding types: SELF_TEST_CHECKLISTS = { 'IDOR': [ 'Test original endpoint with another user\\'s ID → expect 403/404', 'Test all HTTP methods on the same endpoint (GET, PUT, DELETE)', 'Test all sub-resources of the same object', 'Test the list endpoint for the same resource type', 'Test with IDs from different tenants (if multi-tenant)', 'Test with numeric IDs adjacent to own IDs (±1, ±10)', ], 'SQL_INJECTION': [ 'Test with original payload → expect safe response', 'Test with single quote payload → expect safe response', 'Test with comment-based payload (--) → expect safe response', 'Test with UNION-based payload → expect safe response', 'Test with time-based payload (SLEEP/WAITFOR) → timing should be normal', 'Test with all input fields on the same endpoint', 'Test with same input pattern on adjacent endpoints', ], 'CORS_MISCONFIGURATION': [ 'Test with original attacker origin → expect no ACAO header or 403', 'Test with different attacker domain → expect same result', 'Test with null origin → expect no reflected null', 'Test with HTTP variant of trusted domain → expect rejection', 'Test subdomain variations of trusted domain', 'Confirm legitimate origins still work (don\\'t break production)', ], 'AUTHENTICATION_BYPASS': [ 'Test original bypass technique → expect 401/403', 'Test endpoint without any credentials → expect 401', 'Test with expired credentials → expect 401', 'Test with invalid token signature → expect 401', 'Test all HTTP methods without credentials', 'Test adjacent endpoints in same namespace', ], 'SECRETS_IN_BUNDLE': [ 'Download and search current production bundle → no secrets found', 'Search all JS files, not just main bundle (chunk files too)', 'Search source maps if still deployed', 'Verify secret has been rotated (test old secret doesn\\'t work)', 'Scan git history for the same secret (ensure it\\'s removed)', 'Run gitleaks on current codebase', ], }
# Self-test checklist template for common finding types: SELF_TEST_CHECKLISTS = { 'IDOR': [ 'Test original endpoint with another user\\'s ID → expect 403/404', 'Test all HTTP methods on the same endpoint (GET, PUT, DELETE)', 'Test all sub-resources of the same object', 'Test the list endpoint for the same resource type', 'Test with IDs from different tenants (if multi-tenant)', 'Test with numeric IDs adjacent to own IDs (±1, ±10)', ], 'SQL_INJECTION': [ 'Test with original payload → expect safe response', 'Test with single quote payload → expect safe response', 'Test with comment-based payload (--) → expect safe response', 'Test with UNION-based payload → expect safe response', 'Test with time-based payload (SLEEP/WAITFOR) → timing should be normal', 'Test with all input fields on the same endpoint', 'Test with same input pattern on adjacent endpoints', ], 'CORS_MISCONFIGURATION': [ 'Test with original attacker origin → expect no ACAO header or 403', 'Test with different attacker domain → expect same result', 'Test with null origin → expect no reflected null', 'Test with HTTP variant of trusted domain → expect rejection', 'Test subdomain variations of trusted domain', 'Confirm legitimate origins still work (don\\'t break production)', ], 'AUTHENTICATION_BYPASS': [ 'Test original bypass technique → expect 401/403', 'Test endpoint without any credentials → expect 401', 'Test with expired credentials → expect 401', 'Test with invalid token signature → expect 401', 'Test all HTTP methods without credentials', 'Test adjacent endpoints in same namespace', ], 'SECRETS_IN_BUNDLE': [ 'Download and search current production bundle → no secrets found', 'Search all JS files, not just main bundle (chunk files too)', 'Search source maps if still deployed', 'Verify secret has been rotated (test old secret doesn\\'t work)', 'Scan git history for the same secret (ensure it\\'s removed)', 'Run gitleaks on current codebase', ], }
Practice 3: Verify Production Deployment Explicitly
#!/bin/bash # production_deployment_verification.sh # Run this BEFORE requesting retest to confirm fix is in production FINDING_ID=$1 FIX_COMMIT=$2 PRODUCTION_URL=$3 PRODUCTION_API_KEY=$4 echo "=== Production Deployment Verification ===" echo "Finding: $FINDING_ID" echo "Fix Commit: $FIX_COMMIT" # Step 1: Get current deployed version DEPLOYED_VERSION=$(curl -s "$PRODUCTION_URL/api/version" \\ -H "Authorization: Bearer $PRODUCTION_API_KEY" | \\ jq -r '.version') echo "Currently deployed version: $DEPLOYED_VERSION" # Step 2: Check if the fix commit is included in the deployed version # (Requires git to be available and repo to be cloned) IS_INCLUDED=$(git merge-base --is-ancestor "$FIX_COMMIT" "HEAD" && echo "YES" || echo "NO") echo "Fix commit included in deployed build: $IS_INCLUDED" # Step 3: Check application health HEALTH_STATUS=$(curl -s "$PRODUCTION_URL/api/health" | jq -r '.status') echo "Application health: $HEALTH_STATUS" # Step 4: Run specific finding self-test echo "" echo "=== Self-Test Results ===" case $FINDING_ID in "FIND-2026-001") # IDOR finding echo "Testing IDOR remediation..." # Test with another user's resource ID (use known test account IDs) RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \\ "$PRODUCTION_URL/api/v1/documents/[other_user_doc_id]" \\ -H "Authorization: Bearer $PRODUCTION_API_KEY") if [ "$RESPONSE" = "404" ] || [ "$RESPONSE" = "403" ]; then echo "✓ IDOR remediated — got $RESPONSE (expected 403 or 404)" else echo "✗ IDOR NOT remediated — got $RESPONSE (expected 403 or 404)" fi ;; esac echo "" echo "Verification complete. If all checks pass, request retest."
#!/bin/bash # production_deployment_verification.sh # Run this BEFORE requesting retest to confirm fix is in production FINDING_ID=$1 FIX_COMMIT=$2 PRODUCTION_URL=$3 PRODUCTION_API_KEY=$4 echo "=== Production Deployment Verification ===" echo "Finding: $FINDING_ID" echo "Fix Commit: $FIX_COMMIT" # Step 1: Get current deployed version DEPLOYED_VERSION=$(curl -s "$PRODUCTION_URL/api/version" \\ -H "Authorization: Bearer $PRODUCTION_API_KEY" | \\ jq -r '.version') echo "Currently deployed version: $DEPLOYED_VERSION" # Step 2: Check if the fix commit is included in the deployed version # (Requires git to be available and repo to be cloned) IS_INCLUDED=$(git merge-base --is-ancestor "$FIX_COMMIT" "HEAD" && echo "YES" || echo "NO") echo "Fix commit included in deployed build: $IS_INCLUDED" # Step 3: Check application health HEALTH_STATUS=$(curl -s "$PRODUCTION_URL/api/health" | jq -r '.status') echo "Application health: $HEALTH_STATUS" # Step 4: Run specific finding self-test echo "" echo "=== Self-Test Results ===" case $FINDING_ID in "FIND-2026-001") # IDOR finding echo "Testing IDOR remediation..." # Test with another user's resource ID (use known test account IDs) RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \\ "$PRODUCTION_URL/api/v1/documents/[other_user_doc_id]" \\ -H "Authorization: Bearer $PRODUCTION_API_KEY") if [ "$RESPONSE" = "404" ] || [ "$RESPONSE" = "403" ]; then echo "✓ IDOR remediated — got $RESPONSE (expected 403 or 404)" else echo "✗ IDOR NOT remediated — got $RESPONSE (expected 403 or 404)" fi ;; esac echo "" echo "Verification complete. If all checks pass, request retest."
#!/bin/bash # production_deployment_verification.sh # Run this BEFORE requesting retest to confirm fix is in production FINDING_ID=$1 FIX_COMMIT=$2 PRODUCTION_URL=$3 PRODUCTION_API_KEY=$4 echo "=== Production Deployment Verification ===" echo "Finding: $FINDING_ID" echo "Fix Commit: $FIX_COMMIT" # Step 1: Get current deployed version DEPLOYED_VERSION=$(curl -s "$PRODUCTION_URL/api/version" \\ -H "Authorization: Bearer $PRODUCTION_API_KEY" | \\ jq -r '.version') echo "Currently deployed version: $DEPLOYED_VERSION" # Step 2: Check if the fix commit is included in the deployed version # (Requires git to be available and repo to be cloned) IS_INCLUDED=$(git merge-base --is-ancestor "$FIX_COMMIT" "HEAD" && echo "YES" || echo "NO") echo "Fix commit included in deployed build: $IS_INCLUDED" # Step 3: Check application health HEALTH_STATUS=$(curl -s "$PRODUCTION_URL/api/health" | jq -r '.status') echo "Application health: $HEALTH_STATUS" # Step 4: Run specific finding self-test echo "" echo "=== Self-Test Results ===" case $FINDING_ID in "FIND-2026-001") # IDOR finding echo "Testing IDOR remediation..." # Test with another user's resource ID (use known test account IDs) RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \\ "$PRODUCTION_URL/api/v1/documents/[other_user_doc_id]" \\ -H "Authorization: Bearer $PRODUCTION_API_KEY") if [ "$RESPONSE" = "404" ] || [ "$RESPONSE" = "403" ]; then echo "✓ IDOR remediated — got $RESPONSE (expected 403 or 404)" else echo "✗ IDOR NOT remediated — got $RESPONSE (expected 403 or 404)" fi ;; esac echo "" echo "Verification complete. If all checks pass, request retest."
The Retest Report That Closes the Audit Loop
A complete retest report for compliance purposes includes:
SECTION 1: RETEST SCOPE AND METHODOLOGY Test window: [dates] Testing firm: CodeAnt AI Original test reference: [report ID and date] Environment: Production (<https://api.company.com>) Version deployed: v2.14.1 (deployed 2026-02-21) Methodology: For each original finding: 1. Reproduced original exploit technique 2. Executed variation testing (documented per finding) 3. Verified root cause resolution (not just symptom fix) 4. Documented evidence for compliance use SECTION 2: FINDING-BY-FINDING RESULTS FIND-2026-001: IDOR — Cross-User Document Access Original CVSS: 8.3 (High) | Retest Status: REMEDIATED | New CVSS: 0.0 Evidence: Original exploit: GET /api/v1/documents/[other_user_id] → 200 OK (original) Retest result: GET /api/v1/documents/[other_user_id] → 404 Not Found ✓ Variation 1: PUT /api/v1/documents/[other_user_id] → 404 Not Found ✓ Variation 2: DELETE /api/v1/documents/[other_user_id] → 404 Not Found ✓ Variation 3: GET /api/v1/documents/[other_user_id]/versions → 404 ✓ Root cause: DocumentQuery.for_current_user() helper verified in code All Document operations confirmed to use ownership filter [continues for each finding...]
SECTION 1: RETEST SCOPE AND METHODOLOGY Test window: [dates] Testing firm: CodeAnt AI Original test reference: [report ID and date] Environment: Production (<https://api.company.com>) Version deployed: v2.14.1 (deployed 2026-02-21) Methodology: For each original finding: 1. Reproduced original exploit technique 2. Executed variation testing (documented per finding) 3. Verified root cause resolution (not just symptom fix) 4. Documented evidence for compliance use SECTION 2: FINDING-BY-FINDING RESULTS FIND-2026-001: IDOR — Cross-User Document Access Original CVSS: 8.3 (High) | Retest Status: REMEDIATED | New CVSS: 0.0 Evidence: Original exploit: GET /api/v1/documents/[other_user_id] → 200 OK (original) Retest result: GET /api/v1/documents/[other_user_id] → 404 Not Found ✓ Variation 1: PUT /api/v1/documents/[other_user_id] → 404 Not Found ✓ Variation 2: DELETE /api/v1/documents/[other_user_id] → 404 Not Found ✓ Variation 3: GET /api/v1/documents/[other_user_id]/versions → 404 ✓ Root cause: DocumentQuery.for_current_user() helper verified in code All Document operations confirmed to use ownership filter [continues for each finding...]
SECTION 1: RETEST SCOPE AND METHODOLOGY Test window: [dates] Testing firm: CodeAnt AI Original test reference: [report ID and date] Environment: Production (<https://api.company.com>) Version deployed: v2.14.1 (deployed 2026-02-21) Methodology: For each original finding: 1. Reproduced original exploit technique 2. Executed variation testing (documented per finding) 3. Verified root cause resolution (not just symptom fix) 4. Documented evidence for compliance use SECTION 2: FINDING-BY-FINDING RESULTS FIND-2026-001: IDOR — Cross-User Document Access Original CVSS: 8.3 (High) | Retest Status: REMEDIATED | New CVSS: 0.0 Evidence: Original exploit: GET /api/v1/documents/[other_user_id] → 200 OK (original) Retest result: GET /api/v1/documents/[other_user_id] → 404 Not Found ✓ Variation 1: PUT /api/v1/documents/[other_user_id] → 404 Not Found ✓ Variation 2: DELETE /api/v1/documents/[other_user_id] → 404 Not Found ✓ Variation 3: GET /api/v1/documents/[other_user_id]/versions → 404 ✓ Root cause: DocumentQuery.for_current_user() helper verified in code All Document operations confirmed to use ownership filter [continues for each finding...]
The Report Is the Beginning, Not the End
A penetration test report is not a security certification. It is a point-in-time assessment of specific vulnerabilities that existed on a specific date, against a specific version of a specific application. Its value to the organization — and to auditors evaluating the organization's security posture — is determined almost entirely by what happens after the report is delivered.
The remediation process is where security improvements actually happen. The retest is where those improvements are verified as real rather than documented as intentions. The compliance evidence package is where that verification becomes auditable proof that the organization's security controls actually work.
Most penetration testing engagements deliver the report and consider the engagement complete. The remediation, the variation testing, the production deployment verification, the CVSS delta calculation, the compliance evidence packaging — that work is left to the organization's security team, frequently without the technical context needed to do it correctly.
The result is exactly the scenario this guide opened with: findings marked closed that aren't closed, compliance evidence that doesn't withstand auditor scrutiny, and a second penetration test that finds eight of the twelve "fixed" findings from the first test still exploitable.
CodeAnt AI's engagement model includes retest as a standard component — not an upsell, not an optional add-on. The engagement isn't complete until findings are verified remediated. CVSS delta documentation is included in the retest report. And the 48-hour escalation SLA for critical findings means that if a critical vulnerability is confirmed, the remediation-to-verification cycle starts within days, not quarters.
→ Book a 30-minute scoping call. Testing starts within 24 hours.
Continue reading:
What Is AI Penetration Testing? The Complete Deep-Dive Guide ← Pillar
What Is a CVSS Score? A Technical Breakdown for Engineers ← Blog 4
How to Choose an AI Penetration Testing Provider ← Blog 6
Continuous Pentesting vs Annual: The Real Operational Difference ← Batch 3 Blog 15
Exploit Chaining: How Low Findings Become Critical Breaches ← Batch 1 Blog 4
FAQs
How long after remediation should we wait before requesting a retest?
Can we retest some findings and accept the risk on others?
We patched the vulnerability but can't verify in production because of access controls. What do we do?
Our pentest report has 50 findings. How do we prioritize what to fix first?
What's the difference between a retest and a new penetration test?
Table of Contents
Start Your 14-Day Free Trial
AI code reviews, security, and quality trusted by modern engineering teams. No credit card required!
Share blog:









