feat: add fix dialog

This commit is contained in:
2024-12-30 11:19:53 +09:00
parent abacea4f71
commit 6cbeecea87
9 changed files with 275 additions and 92 deletions

View File

@ -1,3 +0,0 @@
:root {
user-select: none;
}

View File

@ -55,11 +55,9 @@ export class BPManager {
}
}
public async runCheck() {
for (const name in this.bpSets) {
this.bpSetMetadatas[name].status = 'CHECKING'
const result = await this.bpSets[name].check()
public runCheckOnce(name: string) {
return this
.bpSets[name].check()
.catch((err) => {
this.bpSetMetadatas[name].status = 'ERROR'
this.bpSetMetadatas[name].errorMessage.push({
@ -69,14 +67,55 @@ export class BPManager {
return undefined
})
.then((result) => {
if (result === undefined)
continue
return
this.bpSetMetadatas[name].compliantResources = result.compliantResources
this.bpSetMetadatas[name].nonCompliantResources = result.nonCompliantResources
this.bpSetMetadatas[name].status = 'FINISHED'
})
}
public runCheckAll(finished = (name: string) => {}) {
const checkJobs =
Object
.values(this.bpSetMetadatas)
.map(({ name }) => {
this.bpSetMetadatas[name].status = 'CHECKING'
return this
.bpSets[name].check()
.catch((err) => {
this.bpSetMetadatas[name].status = 'ERROR'
this.bpSetMetadatas[name].errorMessage.push({
date: new Date(),
message: err
})
return undefined
})
.then((result) => {
if (result === undefined)
return
this.bpSetMetadatas[name].compliantResources = result.compliantResources
this.bpSetMetadatas[name].nonCompliantResources = result.nonCompliantResources
this.bpSetMetadatas[name].status = 'FINISHED'
finished(name)
})
})
return Promise.all(checkJobs)
}
public runFix(name: string, requiredParametersForFix: { name: string, value: string }[]) {
return this
.bpSets[name]
.fix(
this.bpSetMetadatas[name].nonCompliantResources,
requiredParametersForFix
)
}
public readonly getBPSet = (name: string) =>

View File

@ -30,6 +30,11 @@ export class Memorizer {
return newMemo
}
public static reset() {
for (const memorized of Memorizer.memorized.values())
memorized.reset()
}
private memorized = new Map<string, any>()
private constructor (
@ -44,9 +49,14 @@ export class Memorizer {
if (memorized !== undefined)
return memorized
console.log(command.constructor.name, 'Executed.')
const newMemo = await this.client.send(command)
this.memorized.set(hashed, newMemo)
return newMemo
}
private readonly reset = () =>
this.memorized.clear()
}

View File

@ -1,6 +1,7 @@
import express, { Request, Response } from 'express'
import { BPManager } from './BPManager'
import { BPSetMetadata } from './types'
import { Memorizer } from './Memorizer'
export class WebServer {
private readonly app = express()
@ -12,16 +13,20 @@ export class WebServer {
) {
this.app.set('view engine', 'ejs')
this.app.set('views', './views');
this.app.use(express.static('./public'))
this.app.get('/', this.getMainPage.bind(this))
this.app.get('/check_all', this.runCheck.bind(this))
this.app.get('/check', this.runCheckOnce.bind(this))
this.app.get('/check_all', this.runCheckAll.bind(this))
this.app.use('/fix', express.urlencoded())
this.app.post('/fix', this.runFix.bind(this))
this.app.use(this.error404)
this.app.listen(this.port, this.showBanner.bind(this))
}
private getMainPage(_: Request, res: Response) {
private getMainPage(req: Request, res: Response) {
const bpStatus: {
category: string,
metadatas: BPSetMetadata[]
@ -29,23 +34,86 @@ export class WebServer {
const bpMetadatas = this.bpManager.getBPSetMetadatas()
const categories = new Set(bpMetadatas.map((v) => v?.awsService))
const hidePass = req.query['hidePass'] === 'true'
for (const category of categories)
bpStatus.push({
category,
metadatas: bpMetadatas.filter((v) => v.awsService === category)
metadatas: bpMetadatas.filter((v) =>
v.awsService === category &&
(!hidePass || v.nonCompliantResources.length > 0))
})
res.render('index', {
bpStatus,
bpLength: bpMetadatas.length
bpStatus: bpStatus.filter(({ metadatas }) => metadatas.length > 0),
bpLength: bpMetadatas.length,
hidePass
})
}
private async runCheckOnce(req: Request, res: Response) {
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.setHeader('Transfer-Encoding', 'chunked')
private runCheck(_: Request, res: Response) {
void this.bpManager.runCheck()
res.redirect('/')
res.write('<script>setInterval(() => window.scrollTo(0, document.body.scrollHeight), 100)</script>')
res.write('<pre>Start Checking....\n')
const { name, hidePass } = req.query
if (typeof name !== 'string' || name.length < 1) {
res.write('<a href="/">Failed. name not found. Return to Report Page')
res.end()
return
}
Memorizer.reset()
await this.bpManager.runCheckOnce(name)
res.write(`<a href="/?hidePass=${hidePass}">Done. Return to Report Page`)
res.end()
}
private async runCheckAll(req: Request, res: Response) {
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.setHeader('Transfer-Encoding', 'chunked')
const { hidePass } = req.query
res.write('<script>setInterval(() => window.scrollTo(0, document.body.scrollHeight), 100)</script>')
res.write('<pre>Start Checking....\n')
Memorizer.reset()
await this.bpManager.runCheckAll((name) =>
res.write(`${name} - FINISHED\n`))
res.write(`<a href="/?hidePass=${hidePass}">Done. Return to Report Page`)
res.end()
}
private async runFix(req: Request, res: Response) {
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.setHeader('Transfer-Encoding', 'chunked')
res.write('<pre>Start Fixing....\n')
const { name, hidePass } = req.query
if (typeof name !== 'string' || name.length < 1) {
res.write('<a href="/">Failed. name not found. Return to Report Page')
res.end()
return
}
const requiredParametersForFix =
Object
.keys(req.body)
.map((k) => ({ name: k, value: req.body[k] }))
await this.bpManager.runFix(name, requiredParametersForFix)
.catch((error) => {
res.write(error.toString() + '\n')
})
res.write(`<a href="/?hidePass=${hidePass}">Done. Return to Report Page`)
res.end()
}
private error404 (_: Request, res: Response) {

View File

@ -1,67 +1,67 @@
import {
IAMClient,
ListPoliciesCommand,
GetPolicyVersionCommand,
DeletePolicyCommand
} from '@aws-sdk/client-iam'
import { BPSet } from '../../types'
import { Memorizer } from '../../Memorizer'
import { IAMClient, ListPoliciesCommand, GetPolicyVersionCommand } from "@aws-sdk/client-iam";
import { BPSet } from "../../types";
import { Memorizer } from "../../Memorizer";
export class IAMPolicyNoStatementsWithAdminAccess implements BPSet {
private readonly client = new IAMClient({})
private readonly memoClient = Memorizer.memo(this.client)
private readonly getPolicies = async () => {
const response = await this.memoClient.send(new ListPoliciesCommand({ Scope: 'Local' }))
return response.Policies || []
}
private readonly getPolicyDefaultVersions = async (policyArn: string, versionId: string) => {
const response = await this.memoClient.send(
new GetPolicyVersionCommand({ PolicyArn: policyArn, VersionId: versionId })
)
return response.PolicyVersion!
}
export class IAMPolicyNoStatementsWithFullAccess implements BPSet {
private readonly client = new IAMClient({});
private readonly memoClient = Memorizer.memo(this.client);
public readonly check = async () => {
const compliantResources = []
const nonCompliantResources = []
const policies = await this.getPolicies()
const compliantResources: string[] = [];
const nonCompliantResources: string[] = [];
// Fetch all customer-managed IAM policies
const policiesResponse = await this.memoClient.send(
new ListPoliciesCommand({ Scope: "Local" })
);
const policies = policiesResponse.Policies || [];
for (const policy of policies) {
const policyVersion = await this.getPolicyDefaultVersions(policy.Arn!, policy.DefaultVersionId!)
// Get the default version of the policy
const policyVersionResponse = await this.memoClient.send(
new GetPolicyVersionCommand({
PolicyArn: policy.Arn!,
VersionId: policy.DefaultVersionId!,
})
);
const policyDocument = JSON.parse(JSON.stringify(policyVersion.Document)) // Parse Document JSON string
const statements = Array.isArray(policyDocument.Statement)
? policyDocument.Statement
: [policyDocument.Statement]
const policyDocument = JSON.parse(
decodeURIComponent(policyVersionResponse.PolicyVersion!.Document as string)
);
for (const statement of statements) {
if (
statement.Action === '*' &&
statement.Resource === '*' &&
statement.Effect === 'Allow'
) {
nonCompliantResources.push(policy.Arn!)
break
}
}
// Check statements for full access
const hasFullAccess = policyDocument.Statement.some((statement: any) => {
if (statement.Effect === "Deny") return false;
const actions = Array.isArray(statement.Action)
? statement.Action
: [statement.Action];
return actions.some((action: string) => action.endsWith(":*"));
});
if (!nonCompliantResources.includes(policy.Arn!)) {
compliantResources.push(policy.Arn!)
if (hasFullAccess) {
nonCompliantResources.push(policy.Arn!);
} else {
compliantResources.push(policy.Arn!);
}
}
return {
compliantResources,
nonCompliantResources,
requiredParametersForFix: []
}
}
requiredParametersForFix: [],
};
};
public readonly fix = async (nonCompliantResources: string[]) => {
for (const arn of nonCompliantResources) {
await this.client.send(new DeletePolicyCommand({ PolicyArn: arn }))
}
public readonly fix = async (
nonCompliantResources: string[],
requiredParametersForFix: { name: string; value: string }[]
) => {
for (const policyArn of nonCompliantResources) {
// Add logic to remove or modify the statements with full access
// Note: Updating an IAM policy requires creating a new version and setting it as default
console.error(
`Fix operation is not implemented for policy ${policyArn}. Manual intervention is required.`
);
}
};
}

View File

@ -1,7 +1,7 @@
<td>
<div class="btn-group">
<button type="button" class="btn btn-primary">Fix</button>
<button type="button" class="btn btn-secondary">Recheck</button>
<button type="button" class="btn btn-primary" data-bs-toggle="offcanvas" data-bs-target="#fixdialog-<%= metadata.idx %>">Fix</button>
<a href="/check?name=<%= metadata.name %>&hidePass=<% hidePass %>" type="button" class="btn btn-secondary">Recheck</a>
<button type="button" class="btn btn-secondary" data-bs-toggle="collapse" data-bs-target="#detail-<%= metadata.idx %>">Details</button>
<button type="button" class="btn btn-secondary" data-bs-toggle="collapse" data-bs-target="#logs-<%= metadata.idx %>">Logs</button>
</div>

View File

@ -0,0 +1,48 @@
<div class="offcanvas offcanvas-end" data-bs-scroll="true" tabindex="-1" id="fixdialog-<%= metadata.idx %>">
<div class="offcanvas-header d-flex gap-3">
<h3 class="offcanvas-title" style="word-break: break-all;">Fixing <%= metadata.name %></h3>
</div>
<div class="offcanvas-body">
<h3 class="mb-3">Pending operations</h3>
<% metadata.commandUsedInFixFunction.forEach(({ name, reason }) => { %>
<div class="card mb-3">
<h5 class="card-header"><%= name %></h5>
<div class="card-body">
<%= reason %>
</div>
</div>
<% }) %>
<br>
<h3 class="mb-3">Required Parameters</h3>
<form method="POST" action="/fix?name=<%= metadata.name %>&hidePass=<%= hidePass %>">
<% metadata.requiredParametersForFix.forEach((input) => { %>
<div class="mb-3">
<label for="<%= `${metadata.name}-${input.name}` %>" class="form-label"><%= input.name %></label>
<input type="text" class="form-control" name="<%= input.name %>" id="<%= `${metadata.name}-${input.name}` %>" value="<%= input.default %>" required>
<div class="form-text">
<%= input.description %><br />
ex) <code><%= input.example %></code>
</div>
</div>
<% }) %>
<br>
<% if (metadata.isFixFunctionUsesDestructiveCommand) { %>
<div class="alert alert-danger" role="alert">
This Fix Function Has DESTRUCTIVE Command! please review pending operations carefully!
</div>
<% } %>
<div class="alert alert-warning" role="alert">
<%= metadata.adviseBeforeFixFunction %>
</div>
<br>
<button class="btn btn-primary" type="submit">Fix!</button>
</form>
</div>
</div>

View File

@ -17,3 +17,5 @@
<%- include('./bpset_progress.ejs', { metadata }) %>
<%- include('./bpset_actions.ejs', { metadata }) %>
</tr>
<%- include('./bpset_fixdialog.ejs', { metadata }) %>

View File

@ -1,23 +1,42 @@
<%
const metadatas =
bpStatus
.map(({ metadatas }) => metadatas)
.flat()
.filter((v) => v.status === 'FINISHED')
const passCount = metadatas.filter((v) => v.nonCompliantResources.length < 1).length
const failCount = metadatas.filter((v) => v.nonCompliantResources.length > 0).length
const errorCount = metadatas.filter((v) => v.status === 'ERROR').length
%>
<div class="d-flex justify-content-between align-items-end">
<div>
<h1 class="fw-bold">BPSets (<%= bpLength %>)</h1>
<p>Created by Minhyeok Park</p>
</div>
<div class="d-flex">
<div class="d-flex status gap-3 text-center">
<div>
<p>Pass</p>
<p><%= %></p>
<p class="m-0">Pass</p>
<p class="fs-3"><%= passCount %></p>
</div>
<div>
<p>Fail</p>
<p class="m-0">Fail</p>
<p class="fs-3"><%= failCount %></p>
</div>
<div>
<p>Error</p>
<p class="m-0">Error</p>
<p class="fs-3"><%= errorCount %></p>
</div>
</div>
<div class="btn-group">
<a href="/check_all" type="button" class="btn btn-primary">Check All</a>
<a href="/check_all?hidePass=<% hidePass %>" type="button" class="btn btn-primary">Check All</a>
<% if (hidePass) { %>
<a href="/?hidePass=false" type="button" class="btn btn-secondary">Show Pass</a>
<% } else { %>
<a href="/?hidePass=true" type="button" class="btn btn-secondary">Hide Pass</a>
<% } %>
</div>
</div>