Skip to content
Merged
30 changes: 25 additions & 5 deletions .github/workflows/nightvision-evidence.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,14 @@ jobs:
echo "NIGHTVISION_AUTH=javaspringvulny-api" >> $GITHUB_ENV
echo "NIGHTVISION_TOKEN=${{ secrets.NIGHTVISION_TOKEN }}" >> $GITHUB_ENV
echo "NIGHTVISION_SCAN_RESULT=scan_result.sarif" >> $GITHUB_ENV
echo "NIGHTVISION_SCAN_RESULT_MARKDOWN=scan_result.md" >> $GITHUB_ENV
echo "NIGHTVISION_OPENAPI_SPEC=openapi-spec.json" >> $GITHUB_ENV

- name: Extract API documentation from code
run: |
nightvision swagger extract . --target ${NIGHTVISION_TARGET} --lang java || true
if [ ! -e openapi-spec.yml ]; then
cp backup-openapi-spec.yml openapi-spec.yml
nightvision swagger extract . --target ${NIGHTVISION_TARGET} --lang java --file-format json || true
if [ ! -e ${NIGHTVISION_OPENAPI_SPEC} ]; then
cp backup-openapi-spec.json ${NIGHTVISION_OPENAPI_SPEC}
fi

- name: Start the app
Expand All @@ -68,7 +70,11 @@ jobs:
- name: Scan the app
run: |
nightvision scan ${NIGHTVISION_TARGET} --auth ${NIGHTVISION_AUTH} > scan-results.txt
nightvision export sarif -s "$(head -n 1 scan-results.txt)" --swagger-file openapi-spec.yml -o ${NIGHTVISION_SCAN_RESULT}
nightvision export sarif -s "$(head -n 1 scan-results.txt)" --swagger-file ${NIGHTVISION_OPENAPI_SPEC} -o ${NIGHTVISION_SCAN_RESULT}

- name: Convert sarif to markdown
run: |
python sarif_to_markdown.py ${NIGHTVISION_SCAN_RESULT} ${NIGHTVISION_SCAN_RESULT_MARKDOWN}

- name: Upload evidence to the docker package
run: |
Expand All @@ -78,8 +84,22 @@ jobs:
--package-repo-name ${{ vars.DOCKER_REPO }} \
--key "${{ secrets.PRIVATE_KEY }}" \
--key-alias nightvision_evidence_key \
--provider-id ${{ vars.NIGHTVISION_PROVIDER_ID }} \
--predicate ${{ env.NIGHTVISION_SCAN_RESULT }} \
--predicate-type https://in-toto.io/attestation/vulns
--predicate-type ${{ vars.NIGHTVISION_SCAN_RESULT_PREDICATE_TYPE }} \
--markdown ${{ env.NIGHTVISION_SCAN_RESULT_MARKDOWN }}

- name: Upload OpenAPI spec to the docker package
run: |
jf evd create \
--package-name ${{ vars.IMAGE_NAME }} \
--package-version "${{ env.IMAGE_TAG }}" \
--package-repo-name ${{ vars.DOCKER_REPO }} \
--key "${{ secrets.PRIVATE_KEY }}" \
--key-alias nightvision_evidence_key \
--provider-id ${{ vars.NIGHTVISION_PROVIDER_ID }} \
--predicate ${{ env.NIGHTVISION_OPENAPI_SPEC }} \
--predicate-type ${{ vars.NIGHTVISION_OPENAPI_SPEC_PREDICATE_TYPE }}

- name: Publish build info
run: jfrog rt build-publish ${{ vars.BUILD_NAME }} ${{ github.run_number }}
47 changes: 39 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ Refer to [nightvision-evidence.yml](https://github.com/nvsecurity/jfrog-integrat
* `BUILD_NAME` (name of the docker image build)
* `DOCKER_REPO` (name of the JFrog docker repository)
* `IMAGE_NAME` (name of the docker image)
* `NIGHTVISION_PROVIDER_ID` (name of the provider that created the evidence)
* `NIGHTVISION_SCAN_RESULT_PREDICATE_TYPE` (predicate type for the DAST scan result)
* `NIGHTVISION_OPENAPI_SPEC_PREDICATE_TYPE` (predicate type for the OpenAPI spec)
* Configure the following repository secrets in GitHub
* `ARTIFACTORY_ACCESS_TOKEN` (access token for the JFrog artifactory server)
* `JF_USER` (user name of the JFrog artifactory server)
Expand Down Expand Up @@ -97,8 +100,8 @@ Scan the API source code automatically generate the Swagger file
- name: Extract API documentation from code
run: |
nightvision swagger extract . --target ${NIGHTVISION_TARGET} --lang java || true
if [ ! -e openapi-spec.yml ]; then
cp backup-openapi-spec.yml openapi-spec.yml
if [ ! -e ${NIGHTVISION_OPENAPI_SPEC} ]; then
cp backup-openapi-spec.json ${NIGHTVISION_OPENAPI_SPEC}
fi
```

Expand All @@ -117,7 +120,17 @@ Scan the application using the auto-generated Swagger file
- name: Scan the app
run: |
nightvision scan ${NIGHTVISION_TARGET} --auth ${NIGHTVISION_AUTH} > scan-results.txt
nightvision export sarif -s "$(head -n 1 scan-results.txt)" --swagger-file openapi-spec.yml -o ${NIGHTVISION_SCAN_RESULT}
nightvision export sarif -s "$(head -n 1 scan-results.txt)" --swagger-file ${NIGHTVISION_OPENAPI_SPEC} -o ${NIGHTVISION_SCAN_RESULT}
```

### Markdown Conversion

Convert the scan result from Sarif to Markdown

```yaml
- name: Convert sarif to markdown
run: |
python sarif_to_markdown.py ${NIGHTVISION_SCAN_RESULT} ${NIGHTVISION_SCAN_RESULT_MARKDOWN}
```

## Attach DAST Scan Evidence
Expand All @@ -133,13 +146,31 @@ Sign the DAST scan result using the private key and upload it to the docker repo
--package-repo-name ${{ vars.DOCKER_REPO }} \
--key ${{ secrets.PRIVATE_KEY }} \
--key-alias nightvision_evidence_key \
--predicate ${NIGHTVISION_SCAN_RESULT} \
--predicate-type https://in-toto.io/attestation/vulns
--provider-id ${{ vars.NIGHTVISION_PROVIDER_ID }} \
--predicate ${{ env.NIGHTVISION_SCAN_RESULT }} \
--predicate-type ${{ vars.NIGHTVISION_SCAN_RESULT_PREDICATE_TYPE }} \
--markdown ${{ env.NIGHTVISION_SCAN_RESULT_MARKDOWN }}
```

## Attach OpenAPI Spec Evidence

Sign the auto-generated OpenAPI spec using the private key and upload it to the docker repository

```yaml
- name: Upload OpenAPI spec to the docker package
run: |
jf evd create \
--package-name ${{ vars.IMAGE_NAME }} \
--package-version "${{ env.IMAGE_TAG }}" \
--package-repo-name ${{ vars.DOCKER_REPO }} \
--key "${{ secrets.PRIVATE_KEY }}" \
--key-alias nightvision_evidence_key \
--provider-id ${{ vars.NIGHTVISION_PROVIDER_ID }} \
--predicate ${{ env.NIGHTVISION_OPENAPI_SPEC }} \
--predicate-type ${{ vars.NIGHTVISION_OPENAPI_SPEC_PREDICATE_TYPE }}
```

## Note

1. It appears the `jf rt build-docker-create` command will create two different package versions: a version using the GitHub run number and a version using the `sha256` digest. The evidence will be attached to the version using the GitHub run number.
2. NightVision currently uses the `https://in-toto.io/attestation/vulns` predicate type when attaching the evidence. We can switch to a different predicate type if required.
3. In addition to attaching the DAST scan evidence, NightVision can attach the auto-generated Swagger file as evidence as well.
1. It appears the `jf rt build-docker-create` command will create two different package versions: a version using the GitHub run number and a version using the `sha256` digest. The evidence will be attached to the version using the GitHub run number.

255 changes: 255 additions & 0 deletions sarif_to_markdown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
import json
import re
import sys
from os import path


def parse_sarif_file(sarif_data: dict[str]) -> dict[str]:
"""Parse SARIF file and extract relevant information"""

# Extract tool information
tool_info = sarif_data["runs"][0]["tool"]["driver"]
tool_name = tool_info.get("name", "Unknown")
tool_version = tool_info.get("version", "Unknown")
scan_url = tool_info.get("informationUri", "")

# Extract rules
rules = {}
for rule in tool_info.get("rules", []):
rules[rule["id"]] = {
"name": rule.get("name", ""),
"description": rule.get("fullDescription", {}).get("text", ""),
"short_description": rule.get("shortDescription", {}).get("text", ""),
}

# Extract results
results = []
for result in sarif_data["runs"][0].get("results", []):
rule_id = result.get("ruleId", "")
rule_info = rules.get(rule_id, {})
result_data = _convert_result(result, rule_info)
results.append(result_data)

return {
"tool_name": tool_name,
"tool_version": tool_version,
"scan_url": scan_url,
"results": results,
}


def _convert_result(result, rule_info: dict) -> dict:
"""Convert a SARIF result to a simplified dictionary format"""

rule_description = rule_info.get("description", "")

# Extract basic information
vulnerability_name = result.get("message", {}).get("text", "")
properties = result.get("properties", {})

# Extract endpoint information
endpoint = properties.get("path", "")
if not endpoint or endpoint == "-":
url = properties.get("url", "")
endpoint = __extract_endpoint_from_url(url)
if not endpoint or endpoint == "-":
endpoint = __extract_endpoint_from_description(rule_description)

method = __extract_method_from_description(rule_description)

# Extract location information
file_location = "-"
locations = result.get("locations", [])
if locations and "physicalLocation" in locations[0]:
phys_loc = locations[0]["physicalLocation"]
if "artifactLocation" in phys_loc:
file_path = phys_loc["artifactLocation"].get("uri", "")
if "region" in phys_loc and "startLine" in phys_loc["region"]:
line_num = phys_loc["region"]["startLine"]
file_location = f"{file_path}:{line_num}" if file_path != "-" else "-"
elif file_path:
file_location = file_path

result_data = {
"vulnerability": vulnerability_name,
"risk": properties.get("nightvision-risk", "-"),
"confidence": properties.get("nightvision-confidence", "-"),
"security_severity": properties.get("security-severity", "-"),
"endpoint": endpoint,
"method": method,
"parameter": (
", ".join(properties.get("parameter", []))
if properties.get("parameter")
else "-"
),
"payload": properties.get("payload", "-"),
"file_location": file_location,
"issue_id": result.get("partialFingerprints", {}).get(
"nightvisionIssueID/v1", ""
),
}
return result_data


def __extract_endpoint_from_url(url: str) -> str:
"""Extract endpoint path from URL"""

if not url or url == "-":
return "-"
# Extract path component from URL
path_match = re.search(r"(?:https?://[^/]+)?(/[^\s?#]*)", url)
return path_match.group(1) if path_match else "-"


def __extract_endpoint_from_description(description: str) -> str:
"""Extract endpoint path from vulnerability description"""

# Look for patterns like "The `/path/` URL path is vulnerable"
pattern = r"The `([^`]+)` URL path is vulnerable"
match = re.search(pattern, description)
if match:
return match.group(1)
return "-"


def __extract_method_from_description(description: str) -> str:
"""Extract HTTP method from vulnerability description"""

# Look for patterns like "via a METHOD request."
pattern = r"via a (\S+) request"
match = re.search(pattern, description)
if match:
return match.group(1).upper()
return "-"


def generate_markdown_report(parsed_data: dict[str]) -> str:
"""Generate Markdown report from parsed SARIF data"""

tool_name = parsed_data["tool_name"]
tool_version = parsed_data["tool_version"]
scan_url = parsed_data["scan_url"]
results = parsed_data["results"]

# Start building the markdown
markdown = f"""# Security Vulnerability Report

**Tool:** {tool_name} v{tool_version}

**Scan URL:** {scan_url}

## Vulnerability Findings

| Vulnerability | Risk | Confidence | Endpoint | Method | Parameter | Payload | File Location |
|---------------|------|------------|----------|--------|-----------|---------|---------------|
"""

# Add each vulnerability as a table row
for result in results:
payload = result["payload"]
# Escape pipe characters and backticks in payload for markdown table
if payload != "-":
payload = f"`{payload}`"

markdown += f"| {result['vulnerability']} | {result['risk']} | {result['confidence']} | {result['endpoint']} | {result['method']} | {result['parameter']} | {payload} | {result['file_location']} |\n"

# Generate summary statistics
risk_counts = {}
for result in results:
risk = result["risk"]
risk_counts[risk] = risk_counts.get(risk, 0) + 1

markdown += f"""
## Summary Statistics

| Risk Level | Count |
|------------|-------|
"""

for risk, count in sorted(risk_counts.items()):
markdown += f"| {risk} | {count} |\n"

# Generate endpoint summary
endpoint_stats = {}
for result in results:
endpoint = result["endpoint"]
if endpoint not in endpoint_stats:
endpoint_stats[endpoint] = {"count": 0, "highest_risk": "-"}
endpoint_stats[endpoint]["count"] += 1

# Determine highest risk (simple priority: CRITICAL > HIGH > MEDIUM > LOW)
current_risk = result["risk"]
current_highest = endpoint_stats[endpoint]["highest_risk"]

risk_priority = {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1, "-": 0}
if risk_priority.get(current_risk, 0) > risk_priority.get(current_highest, 0):
endpoint_stats[endpoint]["highest_risk"] = current_risk

markdown += f"""
## Affected Endpoints Summary

| Endpoint | Vulnerability Count | Highest Risk |
|----------|-------------------|--------------|
"""

for endpoint, stats in sorted(endpoint_stats.items()):
markdown += f"| {endpoint} | {stats['count']} | {stats['highest_risk']} |\n"

# Generate file location summary
file_stats = {}
for result in results:
file_loc = result["file_location"]
if file_loc != "-" and ":" in file_loc:
file_path, line = file_loc.split(":", 1)
if file_path not in file_stats:
file_stats[file_path] = {}
if line not in file_stats[file_path]:
file_stats[file_path][line] = 0
file_stats[file_path][line] += 1

if file_stats:
markdown += f"""
## File Locations Summary

| File | Line | Vulnerability Count |
|------|------|-------------------|
"""

for file_path, lines in sorted(file_stats.items()):
for line, count in sorted(lines.items()):
markdown += f"| {file_path} | {line} | {count} |\n"

return markdown


if __name__ == "__main__":
if len(sys.argv) != 3:
script_name = path.basename(__file__)
print(f"Usage: python {script_name} <input_sarif_file> <output_markdown_file>")
sys.exit(1)

input_file = sys.argv[1]
output_file = sys.argv[2]

try:
with open(input_file, "r", encoding="utf-8") as f:
sarif_data = json.load(f)

parsed_data = parse_sarif_file(sarif_data)
markdown_report = generate_markdown_report(parsed_data)

with open(output_file, "w", encoding="utf-8") as f:
f.write(markdown_report)

print(f"Successfully converted {input_file} to {output_file}")
print(f"Found {len(parsed_data['results'])} vulnerabilities")

except FileNotFoundError:
print(f"Error: Could not find input file '{input_file}'")
sys.exit(1)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON in SARIF file - {e}")
sys.exit(1)
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
Loading